feat(reader): hash router, zero-flicker reload, honest weighted progress, and smart chapter detection
This commit is contained in:
+163
-21
@@ -319,38 +319,180 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple
|
||||
if len(lines) > 1:
|
||||
content = "\n".join(lines[1:]).strip()
|
||||
|
||||
heading_pattern = re.compile(
|
||||
r"^[ \t]*(?:"
|
||||
r"#{1,3}[ \t]+(.+)|"
|
||||
r"((?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Section|SECTION|Act|ACT|Scene|SCENE)[ \t]+[0-9IVXLCDMivxlcdm\w\.\-]+(?:[ \t]*[:\-–—\.][ \t]*.*|[ \t]+[^\n\r]+)?)"
|
||||
r")[ \t]*$",
|
||||
re.MULTILINE,
|
||||
def detect_text_headings(content: str) -> List[Dict]:
|
||||
"""
|
||||
Detects section and chapter headings with high accuracy across Markdown,
|
||||
numbered sections, explicit keywords, bold titles, and Setext styles.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
line_starts = []
|
||||
curr = 0
|
||||
for l in lines:
|
||||
line_starts.append(curr)
|
||||
curr += len(l) + 1
|
||||
|
||||
kw_regex = re.compile(
|
||||
r"^[ \t]*(?:\*\*)?(?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Часть|ЧАСТЬ|Section|SECTION|Раздел|РАЗДЕЛ|Module|MODULE|Модуль|МОДУЛЬ|Unit|UNIT|Lesson|LESSON|Урок|УРОК|Topic|TOPIC|Тема|ТЕМА|Appendix|APPENDIX|Приложение|ПРИЛОЖЕНИЕ|Act|ACT|Scene|SCENE)[ \t]+(?:[0-9IVXLCDMivxlcdm\w\.\-]+)(?:[ \t]*[:\-–—\.][ \t]*[^\n\r*]+|[ \t]+[^\n\r*]+)?(?:\*\*)?[ \t]*$"
|
||||
)
|
||||
|
||||
matches = list(heading_pattern.finditer(content))
|
||||
headings = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
trimmed = line.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
|
||||
prev_empty = (i == 0) or (not lines[i - 1].strip())
|
||||
next_empty = (i == len(lines) - 1) or (not lines[i + 1].strip())
|
||||
|
||||
# 1. Markdown # heading (# to #####)
|
||||
m_md = re.match(r"^#{1,5}[ \t]+(.+)$", trimmed)
|
||||
if m_md:
|
||||
clean_title = m_md.group(1).strip("#").strip()
|
||||
headings.append({
|
||||
"line_idx": i,
|
||||
"start": line_starts[i],
|
||||
"title": clean_title,
|
||||
"type": "markdown",
|
||||
})
|
||||
continue
|
||||
|
||||
# 2. Setext heading: Title followed by === or ---
|
||||
if i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if prev_empty and len(trimmed) <= 80 and not trimmed.startswith(("-", "*", "#", "`")):
|
||||
if re.match(r"^={3,}$", next_line) or re.match(r"^-{3,}$", next_line):
|
||||
headings.append({
|
||||
"line_idx": i,
|
||||
"start": line_starts[i],
|
||||
"title": trimmed,
|
||||
"type": "setext",
|
||||
})
|
||||
continue
|
||||
|
||||
# 3. Explicit keywords (Chapter 1, Глава 2, Module 3, etc.)
|
||||
if kw_regex.match(trimmed):
|
||||
clean_title = trimmed.strip("*").strip()
|
||||
headings.append({
|
||||
"line_idx": i,
|
||||
"start": line_starts[i],
|
||||
"title": clean_title,
|
||||
"type": "keyword",
|
||||
})
|
||||
continue
|
||||
|
||||
# 4. Bold heading on standalone line: **Title**
|
||||
bold_m = re.match(r"^\*\*([A-ZА-ЯЁ0-9][^\n\r*]{2,70})\*\*$", trimmed)
|
||||
if bold_m and prev_empty:
|
||||
headings.append({
|
||||
"line_idx": i,
|
||||
"start": line_starts[i],
|
||||
"title": bold_m.group(1).strip(),
|
||||
"type": "bold",
|
||||
})
|
||||
continue
|
||||
|
||||
# 5. Numbered sections: e.g. 1. Title, 1.1 Title, 1. Title.
|
||||
num_m = re.match(r"^([0-9]{1,2}(?:\.[0-9]{1,2})*\.?|[0-9]{1,2}\))[ \t]+([A-ZА-ЯЁ][^\n\r]{2,70})$", trimmed)
|
||||
if num_m and prev_empty:
|
||||
is_list = False
|
||||
if i + 1 < len(lines):
|
||||
next_t = lines[i + 1].strip()
|
||||
if re.match(r"^[0-9]{1,2}(?:\.[0-9]{1,2})*\.?[ \t]+", next_t):
|
||||
is_list = True
|
||||
if not is_list and not trimmed.endswith((",", ";")):
|
||||
headings.append({
|
||||
"line_idx": i,
|
||||
"start": line_starts[i],
|
||||
"title": trimmed,
|
||||
"type": "numbered",
|
||||
})
|
||||
continue
|
||||
|
||||
# 6. Standalone ALL CAPS line (e.g. INTRODUCTION TO NETWORKS)
|
||||
next_is_allcaps = (i + 1 < len(lines)) and bool(lines[i + 1].strip()) and lines[i + 1].strip().isupper()
|
||||
if prev_empty and not next_is_allcaps and 4 <= len(trimmed) <= 60 and not trimmed.endswith((",", ";")):
|
||||
if re.match(r"^[A-ZА-ЯЁ0-9\s:\-–—\.]+$", trimmed):
|
||||
letters = re.findall(r"[A-ZА-ЯЁ]", trimmed)
|
||||
words = trimmed.split()
|
||||
if len(letters) >= 4 and (len(words) >= 2 or len(trimmed) >= 6):
|
||||
headings.append({
|
||||
"line_idx": i,
|
||||
"start": line_starts[i],
|
||||
"title": trimmed,
|
||||
"type": "allcaps",
|
||||
})
|
||||
continue
|
||||
|
||||
return headings
|
||||
|
||||
|
||||
def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]:
|
||||
"""
|
||||
Parses plain text or Markdown into readable chapters.
|
||||
Splits by intelligent heading detection (Markdown, numbered sections, keywords)
|
||||
or chunks of ~1000 words.
|
||||
"""
|
||||
content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
if not content:
|
||||
return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}]
|
||||
|
||||
lines = content.split("\n")
|
||||
first_line = lines[0].strip()
|
||||
|
||||
# Extract title from first line or # Heading / <h1...> if default or generic README
|
||||
if title == "Untitled" or title.lower().startswith("readme"):
|
||||
if first_line.startswith("# "):
|
||||
title = first_line.lstrip("# ").strip()
|
||||
if len(lines) > 1:
|
||||
content = "\n".join(lines[1:]).strip()
|
||||
elif first_line.lower().startswith("title:"):
|
||||
title = first_line.split(":", 1)[1].strip()
|
||||
if len(lines) > 1:
|
||||
content = "\n".join(lines[1:]).strip()
|
||||
else:
|
||||
h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", content[:1500], re.IGNORECASE)
|
||||
if h1_match:
|
||||
detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip()
|
||||
if detected:
|
||||
title = detected
|
||||
|
||||
# Extract author from next line if missing
|
||||
if not author:
|
||||
lines = content.split("\n")
|
||||
if lines and lines[0].lower().startswith(("author:", "by:")):
|
||||
author = lines[0].split(":", 1)[1].strip()
|
||||
if len(lines) > 1:
|
||||
content = "\n".join(lines[1:]).strip()
|
||||
|
||||
detected_headings = detect_text_headings(content)
|
||||
chapters: List[Dict[str, str]] = []
|
||||
|
||||
if len(matches) >= 1:
|
||||
if detected_headings:
|
||||
# Pre-chapter intro text
|
||||
intro = content[: matches[0].start()].strip()
|
||||
intro = content[: detected_headings[0]["start"]].strip()
|
||||
if intro and count_words(intro) > 10:
|
||||
chapters.append({"title": "Introduction", "content": intro})
|
||||
is_ru = any(ord(c) > 127 for c in intro[:200])
|
||||
chapters.append({"title": "Введение" if is_ru else "Introduction", "content": intro})
|
||||
|
||||
for i, m in enumerate(matches):
|
||||
h_text = (m.group(1) or m.group(2)).strip()
|
||||
start = m.start()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
||||
for i, h in enumerate(detected_headings):
|
||||
start = h["start"]
|
||||
end = detected_headings[i + 1]["start"] if i + 1 < len(detected_headings) else len(content)
|
||||
body = content[start:end].strip()
|
||||
if body:
|
||||
chapters.append({"title": h_text, "content": body})
|
||||
chapters.append({"title": h["title"], "content": body})
|
||||
|
||||
if not chapters:
|
||||
# No headings detected: split by ~3000 words chunks
|
||||
chunks = split_long_text(content, target_words=3000)
|
||||
if len(chunks) == 1:
|
||||
chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": chunks[0]}]
|
||||
else:
|
||||
# No headings detected: split by ~1000 words chunks if long enough
|
||||
total_w = count_words(content)
|
||||
if total_w > 1200:
|
||||
chunks = split_long_text(content, target_words=1000)
|
||||
is_ru = any(ord(c) > 127 for c in content[:200])
|
||||
prefix = "Часть" if is_ru else "Chapter"
|
||||
for idx, chunk in enumerate(chunks, start=1):
|
||||
chapters.append({"title": f"Chapter {idx}", "content": chunk})
|
||||
chapters.append({"title": f"{prefix} {idx}", "content": chunk})
|
||||
else:
|
||||
chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": content}]
|
||||
|
||||
return title, author, chapters
|
||||
|
||||
|
||||
@@ -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.4">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=3.5">
|
||||
|
||||
<!-- PWA meta and icons -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -1056,7 +1056,7 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script src="/static/js/app.js?v=3.4"></script>
|
||||
<script src="/static/js/app.js?v=3.5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+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