feat(reader): hash router, zero-flicker reload, honest weighted progress, and smart chapter detection
This commit is contained in:
1 parent
f642a131ad
commit
4d40b13bce
5 files changed
+510
-130
No files matched your search
+317
-102
@@ -385,6 +385,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Immediate Pre-flight Routing (Zero-flicker view restoration on F5)
|
||||
// -----------------------------------------------------------------------
|
||||
function applyInitialViewPreflight() {
|
||||
let hash = window.location.hash || '';
|
||||
if (!hash || hash === '#') {
|
||||
try {
|
||||
const lastView = localStorage.getItem('notes_active_view');
|
||||
const lastBookId = localStorage.getItem('notes_active_book_id');
|
||||
if (lastView === 'reader-view') {
|
||||
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
|
||||
} else if (lastView === 'srs-view') {
|
||||
hash = '#srs';
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (hash.startsWith('#reader') || hash === '#bookshelf') {
|
||||
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||
if (srsView) srsView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.remove('hidden');
|
||||
updateMobileActiveTab('reader');
|
||||
if (hash.includes('/book/')) {
|
||||
if (bookshelfContainer) bookshelfContainer.classList.add('hidden');
|
||||
if (readerInterface) readerInterface.classList.remove('hidden');
|
||||
if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...';
|
||||
}
|
||||
} else if (hash.startsWith('#srs')) {
|
||||
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.add('hidden');
|
||||
if (srsView) srsView.classList.remove('hidden');
|
||||
updateMobileActiveTab('srs');
|
||||
}
|
||||
}
|
||||
applyInitialViewPreflight();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Session & Auth Check
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -395,23 +431,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const data = await resp.json();
|
||||
currentUser = data.user;
|
||||
showApp();
|
||||
await loadInitialData();
|
||||
|
||||
// Restore last active view and reading session if page was refreshed
|
||||
try {
|
||||
const lastView = localStorage.getItem('notes_active_view');
|
||||
const lastBookId = localStorage.getItem('notes_active_book_id');
|
||||
if (lastView === 'reader-view') {
|
||||
openReaderView();
|
||||
if (lastBookId) {
|
||||
openBook(parseInt(lastBookId, 10));
|
||||
}
|
||||
} else if (lastView === 'srs-view') {
|
||||
openSrsView();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('State restore error:', e);
|
||||
}
|
||||
await handleRouting();
|
||||
loadInitialData();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
@@ -454,6 +475,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
currentUser = data.user;
|
||||
showApp();
|
||||
await handleRouting();
|
||||
loadInitialData();
|
||||
} else {
|
||||
loginError.textContent = data.detail || 'Неверный логин или пароль';
|
||||
@@ -467,8 +489,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
localStorage.removeItem('notes_token');
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
localStorage.removeItem('notes_active_book_id');
|
||||
try { await fetch('/api/logout', { method: 'POST' }); } catch (e) {}
|
||||
currentUser = null;
|
||||
window.location.hash = '';
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
@@ -984,18 +1008,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Interactive SRS Trainer (5 Modes + Smart Mix)
|
||||
// Interactive SRS Trainer & View Switching (Hash Router Driven)
|
||||
// -----------------------------------------------------------------------
|
||||
srsTrainerBtn.addEventListener('click', () => {
|
||||
openSrsView();
|
||||
window.location.hash = '#srs';
|
||||
});
|
||||
|
||||
srsExitBtn.addEventListener('click', () => {
|
||||
closeSrsView();
|
||||
});
|
||||
|
||||
navBrandHome.addEventListener('click', () => {
|
||||
closeSrsView();
|
||||
window.location.hash = '#dictionary';
|
||||
});
|
||||
|
||||
srsMixBtn.addEventListener('click', () => {
|
||||
@@ -1028,7 +1048,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (target) target.classList.add('active');
|
||||
}
|
||||
|
||||
function openDictionaryView() {
|
||||
function openDictionaryView(updateHash = true) {
|
||||
if (updateHash && window.location.hash !== '#dictionary') {
|
||||
window.location.hash = '#dictionary';
|
||||
return;
|
||||
}
|
||||
srsView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.add('hidden');
|
||||
dictionaryView.classList.remove('hidden');
|
||||
@@ -1037,7 +1061,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadInitialData();
|
||||
}
|
||||
|
||||
function openSrsView(tagFilter = null) {
|
||||
function openSrsView(tagFilter = null, updateHash = true) {
|
||||
const targetHash = tagFilter ? `#srs/${encodeURIComponent(tagFilter)}` : '#srs';
|
||||
if (updateHash && window.location.hash !== targetHash) {
|
||||
window.location.hash = targetHash;
|
||||
return;
|
||||
}
|
||||
dictionaryView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.add('hidden');
|
||||
srsView.classList.remove('hidden');
|
||||
@@ -1047,23 +1076,89 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
function closeSrsView() {
|
||||
srsView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.add('hidden');
|
||||
dictionaryView.classList.remove('hidden');
|
||||
updateMobileActiveTab('dict');
|
||||
try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {}
|
||||
loadInitialData();
|
||||
openDictionaryView(true);
|
||||
}
|
||||
|
||||
function openReaderView() {
|
||||
function openBookshelfView(updateHash = true) {
|
||||
if (updateHash && window.location.hash !== '#reader') {
|
||||
window.location.hash = '#reader';
|
||||
return;
|
||||
}
|
||||
dictionaryView.classList.add('hidden');
|
||||
srsView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.remove('hidden');
|
||||
if (bookshelfContainer) bookshelfContainer.classList.remove('hidden');
|
||||
if (readerInterface) readerInterface.classList.add('hidden');
|
||||
updateMobileActiveTab('reader');
|
||||
try { localStorage.setItem('notes_active_view', 'reader-view'); } catch (e) {}
|
||||
try {
|
||||
localStorage.setItem('notes_active_view', 'reader-view');
|
||||
localStorage.removeItem('notes_active_book_id');
|
||||
} catch (e) {}
|
||||
loadBookshelf();
|
||||
}
|
||||
|
||||
function openReaderView(updateHash = true) {
|
||||
const lastBookId = localStorage.getItem('notes_active_book_id');
|
||||
if (lastBookId) {
|
||||
window.location.hash = `#reader/book/${lastBookId}`;
|
||||
} else {
|
||||
openBookshelfView(updateHash);
|
||||
}
|
||||
}
|
||||
|
||||
let isRouting = false;
|
||||
async function handleRouting() {
|
||||
if (isRouting) return;
|
||||
isRouting = true;
|
||||
try {
|
||||
let hash = window.location.hash || '';
|
||||
if (!hash || hash === '#') {
|
||||
try {
|
||||
const lastView = localStorage.getItem('notes_active_view');
|
||||
const lastBookId = localStorage.getItem('notes_active_book_id');
|
||||
if (lastView === 'reader-view') {
|
||||
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
|
||||
window.location.hash = hash;
|
||||
return;
|
||||
} else if (lastView === 'srs-view') {
|
||||
hash = '#srs';
|
||||
window.location.hash = hash;
|
||||
return;
|
||||
} else {
|
||||
hash = '#dictionary';
|
||||
}
|
||||
} catch (e) {
|
||||
hash = '#dictionary';
|
||||
}
|
||||
}
|
||||
|
||||
const cleanHash = hash.replace(/^#\/?/, '');
|
||||
|
||||
if (!cleanHash || cleanHash === 'dictionary') {
|
||||
openDictionaryView(false);
|
||||
} else if (cleanHash.startsWith('srs')) {
|
||||
const parts = cleanHash.split('/');
|
||||
const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null;
|
||||
openSrsView(tag, false);
|
||||
} else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') {
|
||||
const parts = cleanHash.split('/');
|
||||
if (parts[1] === 'book' && parts[2]) {
|
||||
const bookId = parseInt(parts[2], 10);
|
||||
const chIdx = (parts[3] === 'ch' && parts[4] !== undefined) ? parseInt(parts[4], 10) : null;
|
||||
await openBook(bookId, chIdx, false);
|
||||
} else {
|
||||
openBookshelfView(false);
|
||||
}
|
||||
} else {
|
||||
openDictionaryView(false);
|
||||
}
|
||||
} finally {
|
||||
isRouting = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleRouting);
|
||||
|
||||
function initMobileNavigation() {
|
||||
const tabDict = document.getElementById('tab-nav-dict');
|
||||
const tabSrs = document.getElementById('tab-nav-srs');
|
||||
@@ -1081,9 +1176,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const mobileThemeBtn = document.getElementById('mobile-theme-btn');
|
||||
const mobileLogoutBtn = document.getElementById('mobile-logout-btn');
|
||||
|
||||
if (tabDict) tabDict.addEventListener('click', () => openDictionaryView());
|
||||
if (tabSrs) tabSrs.addEventListener('click', () => openSrsView());
|
||||
if (tabReader) tabReader.addEventListener('click', () => openReaderView());
|
||||
if (tabDict) tabDict.addEventListener('click', () => { window.location.hash = '#dictionary'; });
|
||||
if (tabSrs) tabSrs.addEventListener('click', () => { window.location.hash = '#srs'; });
|
||||
if (tabReader) tabReader.addEventListener('click', () => { window.location.hash = '#reader'; });
|
||||
|
||||
if (tabStats) {
|
||||
tabStats.addEventListener('click', () => {
|
||||
@@ -1158,8 +1253,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
navBrandHome.addEventListener('click', () => openDictionaryView());
|
||||
if (navReaderBtn) navReaderBtn.addEventListener('click', () => openReaderView());
|
||||
navBrandHome.addEventListener('click', () => { window.location.hash = '#dictionary'; });
|
||||
if (navReaderBtn) navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; });
|
||||
initMobileNavigation();
|
||||
|
||||
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
|
||||
@@ -1931,8 +2026,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
async function loadBookshelf() {
|
||||
if (!bookshelfContainer) return;
|
||||
bookshelfContainer.classList.remove('hidden');
|
||||
if (readerInterface) readerInterface.classList.add('hidden');
|
||||
if (!window.location.hash.includes('/book/')) {
|
||||
bookshelfContainer.classList.remove('hidden');
|
||||
if (readerInterface) readerInterface.classList.add('hidden');
|
||||
}
|
||||
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
|
||||
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
|
||||
|
||||
@@ -1958,9 +2055,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'book-card';
|
||||
|
||||
const totalCh = b.chapters_count || 1;
|
||||
const currentCh = (b.current_chapter || 0) + 1;
|
||||
const pct = Math.min(100, Math.round((currentCh / totalCh) * 100));
|
||||
const totalCh = Math.max(1, b.chapters_count || 1);
|
||||
const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0));
|
||||
const pos = (typeof b.current_position === 'number') ? Math.max(0, Math.min(1.0, b.current_position)) : 0.0;
|
||||
|
||||
const progressFraction = (chIndex + pos) / totalCh;
|
||||
const totalPct = Math.min(100, Math.max(0, Math.round(progressFraction * 100)));
|
||||
const chPct = Math.min(100, Math.max(0, Math.round(pos * 100)));
|
||||
|
||||
const progressLabels = totalCh === 1
|
||||
? `<span>1 глава • ${totalPct}%</span><span>${totalPct}%</span>`
|
||||
: `<span>Глава ${chIndex + 1} из ${totalCh} (${chPct}%)</span><span>${totalPct}%</span>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div>
|
||||
@@ -1977,11 +2082,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
</div>
|
||||
<div class="book-progress-wrap">
|
||||
<div class="book-progress-labels">
|
||||
<span>Глава ${currentCh} из ${totalCh}</span>
|
||||
<span>${pct}%</span>
|
||||
${progressLabels}
|
||||
</div>
|
||||
<div class="book-progress-bar-bg">
|
||||
<div class="book-progress-bar-fill" style="width: ${pct}%"></div>
|
||||
<div class="book-progress-bar-fill" style="width: ${totalPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1992,10 +2096,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector('.read-book-btn').addEventListener('click', () => openBook(b.id));
|
||||
card.querySelector('.read-book-btn').addEventListener('click', () => {
|
||||
window.location.hash = `#reader/book/${b.id}`;
|
||||
});
|
||||
card.querySelector('.train-book-btn').addEventListener('click', () => {
|
||||
const bookTag = slugify(b.title);
|
||||
openSrsView(bookTag);
|
||||
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
|
||||
});
|
||||
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
|
||||
if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) {
|
||||
@@ -2008,16 +2114,46 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function openBook(bookId) {
|
||||
async function openBook(bookId, specificChapterIdx = null, updateHash = true) {
|
||||
if (!bookId) return;
|
||||
|
||||
if (readerView) readerView.classList.remove('hidden');
|
||||
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||
if (srsView) srsView.classList.add('hidden');
|
||||
if (bookshelfContainer) bookshelfContainer.classList.add('hidden');
|
||||
if (readerInterface) readerInterface.classList.remove('hidden');
|
||||
updateMobileActiveTab('reader');
|
||||
|
||||
if (currentBook && currentBook.id === bookId) {
|
||||
if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) {
|
||||
if (specificChapterIdx !== currentChapterIndex) {
|
||||
renderChapter(specificChapterIdx, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
if (updateHash) {
|
||||
const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`;
|
||||
if (window.location.hash !== desiredHash) {
|
||||
history.replaceState(null, '', desiredHash);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...';
|
||||
const resp = await fetch(`/api/books/${bookId}`);
|
||||
if (!resp.ok) return alert('Не удалось открыть книгу');
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось открыть книгу');
|
||||
window.location.hash = '#reader';
|
||||
return;
|
||||
}
|
||||
currentBook = await resp.json();
|
||||
|
||||
// Load saved reading position from localStorage or server
|
||||
let targetChapter = currentBook.current_chapter || 0;
|
||||
let targetScrollTop = null;
|
||||
let targetFraction = currentBook.current_position || 0;
|
||||
let targetAnchorIdx = null;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(`notes_book_${bookId}_pos`);
|
||||
@@ -2027,27 +2163,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
targetChapter = parsed.chapter;
|
||||
targetScrollTop = parsed.scrollTop;
|
||||
targetFraction = parsed.fraction;
|
||||
targetAnchorIdx = parsed.anchorIdx;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
currentChapterIndex = targetChapter;
|
||||
if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) {
|
||||
targetChapter = specificChapterIdx;
|
||||
if (specificChapterIdx !== (currentBook.current_chapter || 0)) {
|
||||
targetScrollTop = 0;
|
||||
targetFraction = 0;
|
||||
targetAnchorIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
currentChapterIndex = Math.max(0, Math.min(targetChapter, (currentBook.chapters ? currentBook.chapters.length - 1 : 0)));
|
||||
|
||||
readerBookTitle.textContent = currentBook.title;
|
||||
readerBookAuthor.textContent = currentBook.author || '';
|
||||
|
||||
// Populate chapter selector
|
||||
// Populate chapter selector with visual status markers
|
||||
readerChapterSelect.innerHTML = '';
|
||||
const chapters = currentBook.chapters || [];
|
||||
chapters.forEach((ch, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = idx;
|
||||
opt.textContent = ch.title || `Глава ${idx + 1}`;
|
||||
const statusIcon = idx < currentChapterIndex ? '✔ ' : (idx === currentChapterIndex ? '▶ ' : '○ ');
|
||||
opt.textContent = `${statusIcon}${ch.title || `Глава ${idx + 1}`}`;
|
||||
readerChapterSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
bookshelfContainer.classList.add('hidden');
|
||||
readerInterface.classList.remove('hidden');
|
||||
applyReaderSettings();
|
||||
|
||||
try {
|
||||
@@ -2055,17 +2200,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
localStorage.setItem('notes_active_view', 'reader-view');
|
||||
} catch (e) {}
|
||||
|
||||
renderChapter(currentChapterIndex, targetScrollTop, targetFraction);
|
||||
if (updateHash) {
|
||||
const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`;
|
||||
if (window.location.hash !== desiredHash) {
|
||||
history.replaceState(null, '', desiredHash);
|
||||
}
|
||||
}
|
||||
|
||||
renderChapter(currentChapterIndex, targetScrollTop, targetFraction, targetAnchorIdx);
|
||||
} catch (e) {
|
||||
console.error('Error opening book:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChapter(index, restoreScrollTop = null, restoreFraction = null) {
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -2075,6 +2236,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (chapter.title) {
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = chapter.title;
|
||||
h.setAttribute('data-block-idx', '0');
|
||||
readerContentBody.appendChild(h);
|
||||
}
|
||||
|
||||
@@ -2087,10 +2249,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Parse content into structured blocks (code, headings, lists, quotes, paragraphs)
|
||||
const blocks = parseContentBlocks(chapter.content);
|
||||
|
||||
blocks.forEach(block => {
|
||||
blocks.forEach((block, blockIdx) => {
|
||||
const domIdx = blockIdx + 1;
|
||||
let blockEl = null;
|
||||
|
||||
if (block.type === 'code') {
|
||||
const codeBlock = document.createElement('div');
|
||||
codeBlock.className = 'reader-code-block';
|
||||
blockEl = document.createElement('div');
|
||||
blockEl.className = 'reader-code-block';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'reader-code-header';
|
||||
@@ -2123,12 +2288,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
codeEl.textContent = block.code;
|
||||
pre.appendChild(codeEl);
|
||||
|
||||
codeBlock.appendChild(header);
|
||||
codeBlock.appendChild(pre);
|
||||
readerContentBody.appendChild(codeBlock);
|
||||
blockEl.appendChild(header);
|
||||
blockEl.appendChild(pre);
|
||||
} else if (block.type === 'table') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'reader-table-wrap';
|
||||
blockEl = document.createElement('div');
|
||||
blockEl.className = 'reader-table-wrap';
|
||||
const table = document.createElement('table');
|
||||
table.className = 'reader-table';
|
||||
|
||||
@@ -2163,42 +2327,52 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
}
|
||||
wrap.appendChild(table);
|
||||
readerContentBody.appendChild(wrap);
|
||||
blockEl.appendChild(table);
|
||||
} else if (block.type === 'heading') {
|
||||
const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`;
|
||||
const h = document.createElement(tag);
|
||||
if (block.align) h.style.textAlign = block.align;
|
||||
renderInteractiveSpans(h, block.text, block.text, knownWordsMap);
|
||||
readerContentBody.appendChild(h);
|
||||
blockEl = document.createElement(tag);
|
||||
if (block.align) blockEl.style.textAlign = block.align;
|
||||
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
|
||||
} else if (block.type === 'blockquote') {
|
||||
const bq = document.createElement('blockquote');
|
||||
renderInteractiveSpans(bq, block.text, block.text, knownWordsMap);
|
||||
readerContentBody.appendChild(bq);
|
||||
blockEl = document.createElement('blockquote');
|
||||
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
|
||||
} else if (block.type === 'ul' || block.type === 'ol') {
|
||||
const listEl = document.createElement(block.type);
|
||||
blockEl = document.createElement(block.type);
|
||||
block.items.forEach(itemText => {
|
||||
const li = document.createElement('li');
|
||||
renderInteractiveSpans(li, itemText, itemText, knownWordsMap);
|
||||
listEl.appendChild(li);
|
||||
blockEl.appendChild(li);
|
||||
});
|
||||
readerContentBody.appendChild(listEl);
|
||||
} else if (block.type === 'hr') {
|
||||
readerContentBody.appendChild(document.createElement('hr'));
|
||||
blockEl = document.createElement('hr');
|
||||
} else {
|
||||
// paragraph
|
||||
const p = document.createElement('p');
|
||||
blockEl = document.createElement('p');
|
||||
if (block.align) {
|
||||
p.style.textAlign = block.align;
|
||||
p.classList.add('text-center');
|
||||
blockEl.style.textAlign = block.align;
|
||||
blockEl.classList.add('text-center');
|
||||
}
|
||||
renderInteractiveSpans(p, block.text, block.text, knownWordsMap);
|
||||
readerContentBody.appendChild(p);
|
||||
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
|
||||
}
|
||||
|
||||
if (blockEl) {
|
||||
blockEl.setAttribute('data-block-idx', domIdx.toString());
|
||||
readerContentBody.appendChild(blockEl);
|
||||
}
|
||||
});
|
||||
|
||||
function applyScroll() {
|
||||
if (!readerScrollArea) return;
|
||||
if (restoreAnchorIdx !== null && restoreAnchorIdx !== undefined) {
|
||||
const el = readerContentBody.querySelector(`[data-block-idx="${restoreAnchorIdx}"]`);
|
||||
if (el) {
|
||||
const containerTop = readerScrollArea.getBoundingClientRect().top;
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
readerScrollArea.scrollTop += (elTop - containerTop - 10);
|
||||
updateReaderProgressBar();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (restoreScrollTop !== null && restoreScrollTop > 0) {
|
||||
readerScrollArea.scrollTop = restoreScrollTop;
|
||||
} else if (restoreFraction !== null && restoreFraction > 0) {
|
||||
@@ -2880,37 +3054,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Chapter Navigation & Reader Toolbar
|
||||
// Chapter Navigation & Reader Toolbar (URL Hash Driven)
|
||||
if (readerChapterSelect) {
|
||||
readerChapterSelect.addEventListener('change', () => {
|
||||
renderChapter(parseInt(readerChapterSelect.value, 10), 0, 0);
|
||||
const ch = parseInt(readerChapterSelect.value, 10);
|
||||
if (currentBook) {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (readerPrevChBtn) {
|
||||
readerPrevChBtn.addEventListener('click', () => {
|
||||
if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1, 0, 0);
|
||||
if (currentBook && currentChapterIndex > 0) {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex - 1}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (readerNextChBtn) {
|
||||
readerNextChBtn.addEventListener('click', () => {
|
||||
if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) {
|
||||
renderChapter(currentChapterIndex + 1, 0, 0);
|
||||
if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) {
|
||||
window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex + 1}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (readerBackBtn) {
|
||||
readerBackBtn.addEventListener('click', () => {
|
||||
readerInterface.classList.add('hidden');
|
||||
bookshelfContainer.classList.remove('hidden');
|
||||
try { localStorage.removeItem('notes_active_book_id'); } catch (e) {}
|
||||
loadBookshelf();
|
||||
window.location.hash = '#reader';
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll Position Tracking & Real-Time Chapter Progress
|
||||
// Scroll Position Tracking, Paragraph Anchoring & Real-Time Weighted Progress
|
||||
let readerScrollSaveTimer = null;
|
||||
|
||||
function getReaderScrollMetrics() {
|
||||
@@ -2922,12 +3098,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return { scrollTop, maxScroll, pct, fraction };
|
||||
}
|
||||
|
||||
function getActiveAnchorIndex() {
|
||||
if (!readerScrollArea || !readerContentBody) return null;
|
||||
const blocks = readerContentBody.querySelectorAll('[data-block-idx]');
|
||||
if (!blocks || blocks.length === 0) return null;
|
||||
const containerTop = readerScrollArea.getBoundingClientRect().top;
|
||||
for (let el of blocks) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.bottom >= containerTop + 10) {
|
||||
return parseInt(el.getAttribute('data-block-idx'), 10);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function updateReaderProgressBar() {
|
||||
const { pct } = getReaderScrollMetrics();
|
||||
const { pct, fraction } = getReaderScrollMetrics();
|
||||
if (readerChapterProgressFill) {
|
||||
readerChapterProgressFill.style.width = `${pct}%`;
|
||||
}
|
||||
if (readerScrollPctBadge) {
|
||||
|
||||
if (currentBook) {
|
||||
const totalCh = Math.max(1, (currentBook.chapters ? currentBook.chapters.length : 1));
|
||||
const overallFraction = (currentChapterIndex + fraction) / totalCh;
|
||||
const totalPct = Math.min(100, Math.max(0, Math.round(overallFraction * 100)));
|
||||
|
||||
if (readerScrollPctBadge) {
|
||||
readerScrollPctBadge.textContent = `${pct}%`;
|
||||
readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`;
|
||||
}
|
||||
} else if (readerScrollPctBadge) {
|
||||
readerScrollPctBadge.textContent = `${pct}%`;
|
||||
}
|
||||
}
|
||||
@@ -2937,6 +3137,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateReaderProgressBar();
|
||||
|
||||
const { scrollTop, fraction } = getReaderScrollMetrics();
|
||||
const anchorIdx = getActiveAnchorIndex();
|
||||
|
||||
// 1. Instant local persistence
|
||||
try {
|
||||
@@ -2945,6 +3146,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
chapter: currentChapterIndex,
|
||||
scrollTop: scrollTop,
|
||||
fraction: fraction,
|
||||
anchorIdx: anchorIdx,
|
||||
savedAt: Date.now()
|
||||
}));
|
||||
localStorage.setItem('notes_active_book_id', currentBook.id);
|
||||
@@ -2970,11 +3172,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (currentBook && readerScrollArea) {
|
||||
const { scrollTop, fraction } = getReaderScrollMetrics();
|
||||
const anchorIdx = getActiveAnchorIndex();
|
||||
try {
|
||||
localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({
|
||||
chapter: currentChapterIndex,
|
||||
scrollTop: scrollTop,
|
||||
fraction: fraction,
|
||||
anchorIdx: anchorIdx,
|
||||
savedAt: Date.now()
|
||||
}));
|
||||
localStorage.setItem('notes_active_book_id', currentBook.id);
|
||||
@@ -3003,6 +3207,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function applyReaderSettings() {
|
||||
if (!readerScrollArea) return;
|
||||
const anchorIdx = getActiveAnchorIndex();
|
||||
|
||||
// 1. Font size
|
||||
readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`);
|
||||
@@ -3028,6 +3233,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`);
|
||||
|
||||
updateReaderSettingsUI();
|
||||
|
||||
// Re-anchor scroll position after font/layout change
|
||||
if (anchorIdx !== null && anchorIdx !== undefined) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = readerContentBody.querySelector(`[data-block-idx="${anchorIdx}"]`);
|
||||
if (el) {
|
||||
const containerTop = readerScrollArea.getBoundingClientRect().top;
|
||||
const elTop = el.getBoundingClientRect().top;
|
||||
readerScrollArea.scrollTop += (elTop - containerTop - 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateReaderSettingsUI() {
|
||||
@@ -3220,7 +3437,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
readerTrainBtn.addEventListener('click', () => {
|
||||
if (currentBook) {
|
||||
const bookTag = slugify(currentBook.title);
|
||||
openSrsView(bookTag);
|
||||
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3260,8 +3477,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (resp.ok) {
|
||||
const book = await resp.json();
|
||||
addBookModal.classList.add('hidden');
|
||||
await loadBookshelf();
|
||||
openBook(book.id);
|
||||
window.location.hash = `#reader/book/${book.id}`;
|
||||
} else {
|
||||
alert('Ошибка при сохранении книги');
|
||||
}
|
||||
@@ -3288,8 +3504,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (resp.ok) {
|
||||
const book = await resp.json();
|
||||
bookFileInput.value = '';
|
||||
await loadBookshelf();
|
||||
openBook(book.id);
|
||||
window.location.hash = `#reader/book/${book.id}`;
|
||||
} else {
|
||||
const errData = await resp.json();
|
||||
alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`);
|
||||
|
||||
Reference in new issue
Block a user