feat(reader): hash router, zero-flicker reload, honest weighted progress, and smart chapter detection
This commit is contained in:
@@ -120,21 +120,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. 🔄 Комплексная переработка навигации, роутинга и честного прогресса чтения (In Progress)
|
### 6. 🔄 Комплексная переработка навигации, роутинга и честного прогресса чтения (Completed)
|
||||||
- [ ] **6.1. Нативная маршрутизация окон (URL Hash Router & Zero-Flicker F5)**:
|
- [x] **6.1. Нативная маршрутизация окон (URL Hash Router & Zero-Flicker F5)**:
|
||||||
- Внедрение хэш-роутинга: `#dictionary`, `#srs`, `#bookshelf`, `#reader/:book_id`, `#reader/:book_id/ch/:chapter_idx`.
|
- Внедрение хэш-роутинга: `#dictionary`, `#srs`, `#bookshelf`, `#reader/:book_id`, `#reader/:book_id/ch/:chapter_idx`.
|
||||||
- Синхронное переключение `view` при старте скрипта (до любых сетевых запросов авторизации): если в URL `#reader/...`, сразу скрывать `#dictionary-view` и показывать скелетон читалки, устраняя мигание словарем.
|
- Синхронное переключение `view` при старте скрипта (до любых сетевых запросов авторизации): если в URL `#reader/...`, сразу скрывать `#dictionary-view` и показывать скелетон читалки, устраняя мигание словарем.
|
||||||
- Развязка асинхронных вызовов: `loadBookshelf()` больше не должен принудительно гасить `readerInterface`, если уже инициировано открытие книги через `openBook()`.
|
- Развязка асинхронных вызовов: `loadBookshelf()` больше не должен принудительно гасить `readerInterface`, если уже инициировано открытие книги через `openBook()`.
|
||||||
- Поддержка нативной истории браузера (кнопки «Назад» / «Вперёд» на мыши, клавиатуре и мобильных свайпах).
|
- Поддержка нативной истории браузера (кнопки «Назад» / «Вперёд» на мыши, клавиатуре и мобильных свайпах).
|
||||||
- Автоматическая синхронизация активного экрана в URL и `localStorage`.
|
- Автоматическая синхронизация активного экрана в URL и `localStorage`.
|
||||||
- [ ] **6.2. Честный непрерывный расчет прогресса книги и главы (Weighted Reading Progress)**:
|
- [x] **6.2. Честный непрерывный расчет прогресса книги и главы (Weighted Reading Progress)**:
|
||||||
- Устранение бага ложных «100%»: полная ликвидация формулы `(current_ch + 1) / total_ch`, выдававшей 100% на первой странице одноглавных документов.
|
- Устранение бага ложных «100%»: полная ликвидация формулы `(current_ch + 1) / total_ch`, выдававшей 100% на первой странице одноглавных документов.
|
||||||
- Прогресс текущей главы в реальном времени: непрерывный расчет от вертикального скролла (`scrollTop / (scrollHeight - clientHeight) * 100`) от 0% до 100%.
|
- Прогресс текущей главы в реальном времени: непрерывный расчет от вертикального скролла (`scrollTop / (scrollHeight - clientHeight) * 100`) от 0% до 100%.
|
||||||
- Взвешенный общий прогресс книги: расчет по объему слов прочитанных глав + точная доля текущей главы:
|
- Взвешенный общий прогресс книги: расчет по объему слов прочитанных глав + точная доля текущей главы:
|
||||||
$$\text{TotalProgress} = \frac{\sum_{i=0}^{\text{cur}-1} \text{words}(ch_i) + \text{words}(ch_{\text{cur}}) \times \frac{\text{ChapterPct}}{100}}{\text{TotalWords}} \times 100\%$$
|
$$\text{TotalProgress} = \frac{\sum_{i=0}^{\text{cur}-1} \text{words}(ch_i) + \text{words}(ch_{\text{cur}}) \times \frac{\text{ChapterPct}}{100}}{\text{TotalWords}} \times 100\%$$
|
||||||
- Для документа на 1000 слов прогресс будет плавно расти: 0% $\rightarrow$ 15% $\rightarrow$ 50% $\rightarrow$ 100%.
|
- Для документа на 1000 слов прогресс будет плавно расти: 0% $\rightarrow$ 15% $\rightarrow$ 50% $\rightarrow$ 100%.
|
||||||
- Отображение честного суммарного прогресса на карточках книг в библиотеке и в тулбаре читалки.
|
- Отображение честного суммарного прогресса на карточках книг в библиотеке и в тулбаре читалки.
|
||||||
- [ ] **6.3. Интеллектуальный парсинг глав и оглавление (Smart Chapter & ToC Detection)**:
|
- [x] **6.3. Интеллектуальный парсинг глав и оглавление (Smart Chapter & ToC Detection)**:
|
||||||
- Расширение детектора глав в `app/reader.py`:
|
- Расширение детектора глав в `app/reader.py`:
|
||||||
- Markdown-заголовки всех уровней (`# `, `## `, `### `, `#### `).
|
- Markdown-заголовки всех уровней (`# `, `## `, `### `, `#### `).
|
||||||
- Нумерованные разделы и модули (`1. `, `1.1 `, `2.0 `, `Section 1`, `Part 1`, `Раздел 1`).
|
- Нумерованные разделы и модули (`1. `, `1.1 `, `2.0 `, `Section 1`, `Part 1`, `Раздел 1`).
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
- Жирные заголовки в начале абзацев (`**Chapter 1**`, `**Глава 1**`).
|
- Жирные заголовки в начале абзацев (`**Chapter 1**`, `**Глава 1**`).
|
||||||
- Адаптивный порог авто-разбивки: снижение порога чанкования текста без явных глав с 3000 до 800–1000 слов, чтобы даже небольшие статьи имели логическое деление на части.
|
- Адаптивный порог авто-разбивки: снижение порога чанкования текста без явных глав с 3000 до 800–1000 слов, чтобы даже небольшие статьи имели логическое деление на части.
|
||||||
- Интерактивное оглавление со статусами прочтения: `✔ Прочитано`, `▶ В процессе (45%)`, `○ Не начато`.
|
- Интерактивное оглавление со статусами прочтения: `✔ Прочитано`, `▶ В процессе (45%)`, `○ Не начато`.
|
||||||
- [ ] **6.4. Устойчивая фиксация позиции чтения (Paragraph Anchor + Scroll)**:
|
- [x] **6.4. Устойчивая фиксация позиции чтения (Paragraph Anchor + Scroll)**:
|
||||||
- Сохранение не только пикселей `scrollTop`, но и индекса первого видимого абзаца (`anchor_paragraph_idx`).
|
- Сохранение не только пикселей `scrollTop`, но и индекса первого видимого абзаца (`anchor_paragraph_idx`).
|
||||||
- Устойчивость к изменению шрифта `Aa` и ориентации экрана: восстановление скролла к конкретному предложению/абзацу, а не слепым пикселям.
|
- Устойчивость к изменению шрифта `Aa` и ориентации экрана: восстановление скролла к конкретному предложению/абзацу, а не слепым пикселям.
|
||||||
- Фиксация скролл-контейнера `#reader-scroll-area` с аппаратным ускорением на мобильных.
|
- Фиксация скролл-контейнера `#reader-scroll-area` с аппаратным ускорением на мобильных.
|
||||||
|
|||||||
+163
-21
@@ -319,38 +319,180 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple
|
|||||||
if len(lines) > 1:
|
if len(lines) > 1:
|
||||||
content = "\n".join(lines[1:]).strip()
|
content = "\n".join(lines[1:]).strip()
|
||||||
|
|
||||||
heading_pattern = re.compile(
|
def detect_text_headings(content: str) -> List[Dict]:
|
||||||
r"^[ \t]*(?:"
|
"""
|
||||||
r"#{1,3}[ \t]+(.+)|"
|
Detects section and chapter headings with high accuracy across Markdown,
|
||||||
r"((?:Chapter|CHAPTER|Глава|ГЛАВА|Part|PART|Section|SECTION|Act|ACT|Scene|SCENE)[ \t]+[0-9IVXLCDMivxlcdm\w\.\-]+(?:[ \t]*[:\-–—\.][ \t]*.*|[ \t]+[^\n\r]+)?)"
|
numbered sections, explicit keywords, bold titles, and Setext styles.
|
||||||
r")[ \t]*$",
|
"""
|
||||||
re.MULTILINE,
|
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]] = []
|
chapters: List[Dict[str, str]] = []
|
||||||
|
|
||||||
if len(matches) >= 1:
|
if detected_headings:
|
||||||
# Pre-chapter intro text
|
# Pre-chapter intro text
|
||||||
intro = content[: matches[0].start()].strip()
|
intro = content[: detected_headings[0]["start"]].strip()
|
||||||
if intro and count_words(intro) > 10:
|
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):
|
for i, h in enumerate(detected_headings):
|
||||||
h_text = (m.group(1) or m.group(2)).strip()
|
start = h["start"]
|
||||||
start = m.start()
|
end = detected_headings[i + 1]["start"] if i + 1 < len(detected_headings) else len(content)
|
||||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
|
||||||
body = content[start:end].strip()
|
body = content[start:end].strip()
|
||||||
if body:
|
if body:
|
||||||
chapters.append({"title": h_text, "content": body})
|
chapters.append({"title": h["title"], "content": body})
|
||||||
|
|
||||||
if not chapters:
|
if not chapters:
|
||||||
# No headings detected: split by ~3000 words chunks
|
# No headings detected: split by ~1000 words chunks if long enough
|
||||||
chunks = split_long_text(content, target_words=3000)
|
total_w = count_words(content)
|
||||||
if len(chunks) == 1:
|
if total_w > 1200:
|
||||||
chapters = [{"title": title if title != "Untitled" else "Chapter 1", "content": chunks[0]}]
|
chunks = split_long_text(content, target_words=1000)
|
||||||
else:
|
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):
|
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
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
<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 -->
|
<!-- PWA meta and icons -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
@@ -1056,7 +1056,7 @@
|
|||||||
<!-- Audio Element for native streaming -->
|
<!-- Audio Element for native streaming -->
|
||||||
<audio id="global-audio-player" preload="none"></audio>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+314
-99
@@ -385,6 +385,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
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
|
// Session & Auth Check
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -395,23 +431,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
showApp();
|
showApp();
|
||||||
await loadInitialData();
|
await handleRouting();
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
@@ -454,6 +475,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
showApp();
|
showApp();
|
||||||
|
await handleRouting();
|
||||||
loadInitialData();
|
loadInitialData();
|
||||||
} else {
|
} else {
|
||||||
loginError.textContent = data.detail || 'Неверный логин или пароль';
|
loginError.textContent = data.detail || 'Неверный логин или пароль';
|
||||||
@@ -467,8 +489,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
logoutBtn.addEventListener('click', async () => {
|
logoutBtn.addEventListener('click', async () => {
|
||||||
localStorage.removeItem('notes_token');
|
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;
|
currentUser = null;
|
||||||
|
window.location.hash = '';
|
||||||
window.location.reload();
|
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', () => {
|
srsTrainerBtn.addEventListener('click', () => {
|
||||||
openSrsView();
|
window.location.hash = '#srs';
|
||||||
});
|
});
|
||||||
|
|
||||||
srsExitBtn.addEventListener('click', () => {
|
srsExitBtn.addEventListener('click', () => {
|
||||||
closeSrsView();
|
window.location.hash = '#dictionary';
|
||||||
});
|
|
||||||
|
|
||||||
navBrandHome.addEventListener('click', () => {
|
|
||||||
closeSrsView();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
srsMixBtn.addEventListener('click', () => {
|
srsMixBtn.addEventListener('click', () => {
|
||||||
@@ -1028,7 +1048,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (target) target.classList.add('active');
|
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');
|
srsView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.add('hidden');
|
if (readerView) readerView.classList.add('hidden');
|
||||||
dictionaryView.classList.remove('hidden');
|
dictionaryView.classList.remove('hidden');
|
||||||
@@ -1037,7 +1061,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadInitialData();
|
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');
|
dictionaryView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.add('hidden');
|
if (readerView) readerView.classList.add('hidden');
|
||||||
srsView.classList.remove('hidden');
|
srsView.classList.remove('hidden');
|
||||||
@@ -1047,23 +1076,89 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeSrsView() {
|
function closeSrsView() {
|
||||||
srsView.classList.add('hidden');
|
openDictionaryView(true);
|
||||||
if (readerView) readerView.classList.add('hidden');
|
|
||||||
dictionaryView.classList.remove('hidden');
|
|
||||||
updateMobileActiveTab('dict');
|
|
||||||
try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {}
|
|
||||||
loadInitialData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReaderView() {
|
function openBookshelfView(updateHash = true) {
|
||||||
|
if (updateHash && window.location.hash !== '#reader') {
|
||||||
|
window.location.hash = '#reader';
|
||||||
|
return;
|
||||||
|
}
|
||||||
dictionaryView.classList.add('hidden');
|
dictionaryView.classList.add('hidden');
|
||||||
srsView.classList.add('hidden');
|
srsView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.remove('hidden');
|
if (readerView) readerView.classList.remove('hidden');
|
||||||
|
if (bookshelfContainer) bookshelfContainer.classList.remove('hidden');
|
||||||
|
if (readerInterface) readerInterface.classList.add('hidden');
|
||||||
updateMobileActiveTab('reader');
|
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();
|
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() {
|
function initMobileNavigation() {
|
||||||
const tabDict = document.getElementById('tab-nav-dict');
|
const tabDict = document.getElementById('tab-nav-dict');
|
||||||
const tabSrs = document.getElementById('tab-nav-srs');
|
const tabSrs = document.getElementById('tab-nav-srs');
|
||||||
@@ -1081,9 +1176,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const mobileThemeBtn = document.getElementById('mobile-theme-btn');
|
const mobileThemeBtn = document.getElementById('mobile-theme-btn');
|
||||||
const mobileLogoutBtn = document.getElementById('mobile-logout-btn');
|
const mobileLogoutBtn = document.getElementById('mobile-logout-btn');
|
||||||
|
|
||||||
if (tabDict) tabDict.addEventListener('click', () => openDictionaryView());
|
if (tabDict) tabDict.addEventListener('click', () => { window.location.hash = '#dictionary'; });
|
||||||
if (tabSrs) tabSrs.addEventListener('click', () => openSrsView());
|
if (tabSrs) tabSrs.addEventListener('click', () => { window.location.hash = '#srs'; });
|
||||||
if (tabReader) tabReader.addEventListener('click', () => openReaderView());
|
if (tabReader) tabReader.addEventListener('click', () => { window.location.hash = '#reader'; });
|
||||||
|
|
||||||
if (tabStats) {
|
if (tabStats) {
|
||||||
tabStats.addEventListener('click', () => {
|
tabStats.addEventListener('click', () => {
|
||||||
@@ -1158,8 +1253,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navBrandHome.addEventListener('click', () => openDictionaryView());
|
navBrandHome.addEventListener('click', () => { window.location.hash = '#dictionary'; });
|
||||||
if (navReaderBtn) navReaderBtn.addEventListener('click', () => openReaderView());
|
if (navReaderBtn) navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; });
|
||||||
initMobileNavigation();
|
initMobileNavigation();
|
||||||
|
|
||||||
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
|
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
|
||||||
@@ -1931,8 +2026,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
async function loadBookshelf() {
|
async function loadBookshelf() {
|
||||||
if (!bookshelfContainer) return;
|
if (!bookshelfContainer) return;
|
||||||
|
if (!window.location.hash.includes('/book/')) {
|
||||||
bookshelfContainer.classList.remove('hidden');
|
bookshelfContainer.classList.remove('hidden');
|
||||||
if (readerInterface) readerInterface.classList.add('hidden');
|
if (readerInterface) readerInterface.classList.add('hidden');
|
||||||
|
}
|
||||||
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
|
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
|
||||||
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
|
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
|
||||||
|
|
||||||
@@ -1958,9 +2055,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'book-card';
|
card.className = 'book-card';
|
||||||
|
|
||||||
const totalCh = b.chapters_count || 1;
|
const totalCh = Math.max(1, b.chapters_count || 1);
|
||||||
const currentCh = (b.current_chapter || 0) + 1;
|
const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0));
|
||||||
const pct = Math.min(100, Math.round((currentCh / totalCh) * 100));
|
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 = `
|
card.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
@@ -1977,11 +2082,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="book-progress-wrap">
|
<div class="book-progress-wrap">
|
||||||
<div class="book-progress-labels">
|
<div class="book-progress-labels">
|
||||||
<span>Глава ${currentCh} из ${totalCh}</span>
|
${progressLabels}
|
||||||
<span>${pct}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="book-progress-bar-bg">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1992,10 +2096,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</div>
|
</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', () => {
|
card.querySelector('.train-book-btn').addEventListener('click', () => {
|
||||||
const bookTag = slugify(b.title);
|
const bookTag = slugify(b.title);
|
||||||
openSrsView(bookTag);
|
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
|
||||||
});
|
});
|
||||||
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
|
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
|
||||||
if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) {
|
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 {
|
try {
|
||||||
|
if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...';
|
||||||
const resp = await fetch(`/api/books/${bookId}`);
|
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();
|
currentBook = await resp.json();
|
||||||
|
|
||||||
// Load saved reading position from localStorage or server
|
// Load saved reading position from localStorage or server
|
||||||
let targetChapter = currentBook.current_chapter || 0;
|
let targetChapter = currentBook.current_chapter || 0;
|
||||||
let targetScrollTop = null;
|
let targetScrollTop = null;
|
||||||
let targetFraction = currentBook.current_position || 0;
|
let targetFraction = currentBook.current_position || 0;
|
||||||
|
let targetAnchorIdx = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(`notes_book_${bookId}_pos`);
|
const raw = localStorage.getItem(`notes_book_${bookId}_pos`);
|
||||||
@@ -2027,27 +2163,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
targetChapter = parsed.chapter;
|
targetChapter = parsed.chapter;
|
||||||
targetScrollTop = parsed.scrollTop;
|
targetScrollTop = parsed.scrollTop;
|
||||||
targetFraction = parsed.fraction;
|
targetFraction = parsed.fraction;
|
||||||
|
targetAnchorIdx = parsed.anchorIdx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} 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;
|
readerBookTitle.textContent = currentBook.title;
|
||||||
readerBookAuthor.textContent = currentBook.author || '';
|
readerBookAuthor.textContent = currentBook.author || '';
|
||||||
|
|
||||||
// Populate chapter selector
|
// Populate chapter selector with visual status markers
|
||||||
readerChapterSelect.innerHTML = '';
|
readerChapterSelect.innerHTML = '';
|
||||||
const chapters = currentBook.chapters || [];
|
const chapters = currentBook.chapters || [];
|
||||||
chapters.forEach((ch, idx) => {
|
chapters.forEach((ch, idx) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = idx;
|
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);
|
readerChapterSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
bookshelfContainer.classList.add('hidden');
|
|
||||||
readerInterface.classList.remove('hidden');
|
|
||||||
applyReaderSettings();
|
applyReaderSettings();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2055,17 +2200,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
localStorage.setItem('notes_active_view', 'reader-view');
|
localStorage.setItem('notes_active_view', 'reader-view');
|
||||||
} catch (e) {}
|
} 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) {
|
} catch (e) {
|
||||||
console.error('Error opening book:', 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;
|
if (!currentBook || !currentBook.chapters) return;
|
||||||
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
|
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
|
||||||
readerChapterSelect.value = currentChapterIndex;
|
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;
|
readerPrevChBtn.disabled = currentChapterIndex <= 0;
|
||||||
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
|
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
|
||||||
|
|
||||||
@@ -2075,6 +2236,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (chapter.title) {
|
if (chapter.title) {
|
||||||
const h = document.createElement('h2');
|
const h = document.createElement('h2');
|
||||||
h.textContent = chapter.title;
|
h.textContent = chapter.title;
|
||||||
|
h.setAttribute('data-block-idx', '0');
|
||||||
readerContentBody.appendChild(h);
|
readerContentBody.appendChild(h);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2087,10 +2249,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Parse content into structured blocks (code, headings, lists, quotes, paragraphs)
|
// Parse content into structured blocks (code, headings, lists, quotes, paragraphs)
|
||||||
const blocks = parseContentBlocks(chapter.content);
|
const blocks = parseContentBlocks(chapter.content);
|
||||||
|
|
||||||
blocks.forEach(block => {
|
blocks.forEach((block, blockIdx) => {
|
||||||
|
const domIdx = blockIdx + 1;
|
||||||
|
let blockEl = null;
|
||||||
|
|
||||||
if (block.type === 'code') {
|
if (block.type === 'code') {
|
||||||
const codeBlock = document.createElement('div');
|
blockEl = document.createElement('div');
|
||||||
codeBlock.className = 'reader-code-block';
|
blockEl.className = 'reader-code-block';
|
||||||
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'reader-code-header';
|
header.className = 'reader-code-header';
|
||||||
@@ -2123,12 +2288,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
codeEl.textContent = block.code;
|
codeEl.textContent = block.code;
|
||||||
pre.appendChild(codeEl);
|
pre.appendChild(codeEl);
|
||||||
|
|
||||||
codeBlock.appendChild(header);
|
blockEl.appendChild(header);
|
||||||
codeBlock.appendChild(pre);
|
blockEl.appendChild(pre);
|
||||||
readerContentBody.appendChild(codeBlock);
|
|
||||||
} else if (block.type === 'table') {
|
} else if (block.type === 'table') {
|
||||||
const wrap = document.createElement('div');
|
blockEl = document.createElement('div');
|
||||||
wrap.className = 'reader-table-wrap';
|
blockEl.className = 'reader-table-wrap';
|
||||||
const table = document.createElement('table');
|
const table = document.createElement('table');
|
||||||
table.className = 'reader-table';
|
table.className = 'reader-table';
|
||||||
|
|
||||||
@@ -2163,42 +2327,52 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
table.appendChild(tbody);
|
table.appendChild(tbody);
|
||||||
}
|
}
|
||||||
wrap.appendChild(table);
|
blockEl.appendChild(table);
|
||||||
readerContentBody.appendChild(wrap);
|
|
||||||
} else if (block.type === 'heading') {
|
} else if (block.type === 'heading') {
|
||||||
const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`;
|
const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`;
|
||||||
const h = document.createElement(tag);
|
blockEl = document.createElement(tag);
|
||||||
if (block.align) h.style.textAlign = block.align;
|
if (block.align) blockEl.style.textAlign = block.align;
|
||||||
renderInteractiveSpans(h, block.text, block.text, knownWordsMap);
|
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
|
||||||
readerContentBody.appendChild(h);
|
|
||||||
} else if (block.type === 'blockquote') {
|
} else if (block.type === 'blockquote') {
|
||||||
const bq = document.createElement('blockquote');
|
blockEl = document.createElement('blockquote');
|
||||||
renderInteractiveSpans(bq, block.text, block.text, knownWordsMap);
|
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
|
||||||
readerContentBody.appendChild(bq);
|
|
||||||
} else if (block.type === 'ul' || block.type === 'ol') {
|
} else if (block.type === 'ul' || block.type === 'ol') {
|
||||||
const listEl = document.createElement(block.type);
|
blockEl = document.createElement(block.type);
|
||||||
block.items.forEach(itemText => {
|
block.items.forEach(itemText => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
renderInteractiveSpans(li, itemText, itemText, knownWordsMap);
|
renderInteractiveSpans(li, itemText, itemText, knownWordsMap);
|
||||||
listEl.appendChild(li);
|
blockEl.appendChild(li);
|
||||||
});
|
});
|
||||||
readerContentBody.appendChild(listEl);
|
|
||||||
} else if (block.type === 'hr') {
|
} else if (block.type === 'hr') {
|
||||||
readerContentBody.appendChild(document.createElement('hr'));
|
blockEl = document.createElement('hr');
|
||||||
} else {
|
} else {
|
||||||
// paragraph
|
// paragraph
|
||||||
const p = document.createElement('p');
|
blockEl = document.createElement('p');
|
||||||
if (block.align) {
|
if (block.align) {
|
||||||
p.style.textAlign = block.align;
|
blockEl.style.textAlign = block.align;
|
||||||
p.classList.add('text-center');
|
blockEl.classList.add('text-center');
|
||||||
}
|
}
|
||||||
renderInteractiveSpans(p, block.text, block.text, knownWordsMap);
|
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
|
||||||
readerContentBody.appendChild(p);
|
}
|
||||||
|
|
||||||
|
if (blockEl) {
|
||||||
|
blockEl.setAttribute('data-block-idx', domIdx.toString());
|
||||||
|
readerContentBody.appendChild(blockEl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function applyScroll() {
|
function applyScroll() {
|
||||||
if (!readerScrollArea) return;
|
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) {
|
if (restoreScrollTop !== null && restoreScrollTop > 0) {
|
||||||
readerScrollArea.scrollTop = restoreScrollTop;
|
readerScrollArea.scrollTop = restoreScrollTop;
|
||||||
} else if (restoreFraction !== null && restoreFraction > 0) {
|
} 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) {
|
if (readerChapterSelect) {
|
||||||
readerChapterSelect.addEventListener('change', () => {
|
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) {
|
if (readerPrevChBtn) {
|
||||||
readerPrevChBtn.addEventListener('click', () => {
|
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) {
|
if (readerNextChBtn) {
|
||||||
readerNextChBtn.addEventListener('click', () => {
|
readerNextChBtn.addEventListener('click', () => {
|
||||||
if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) {
|
if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) {
|
||||||
renderChapter(currentChapterIndex + 1, 0, 0);
|
window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex + 1}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (readerBackBtn) {
|
if (readerBackBtn) {
|
||||||
readerBackBtn.addEventListener('click', () => {
|
readerBackBtn.addEventListener('click', () => {
|
||||||
readerInterface.classList.add('hidden');
|
window.location.hash = '#reader';
|
||||||
bookshelfContainer.classList.remove('hidden');
|
|
||||||
try { localStorage.removeItem('notes_active_book_id'); } catch (e) {}
|
|
||||||
loadBookshelf();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll Position Tracking & Real-Time Chapter Progress
|
// Scroll Position Tracking, Paragraph Anchoring & Real-Time Weighted Progress
|
||||||
let readerScrollSaveTimer = null;
|
let readerScrollSaveTimer = null;
|
||||||
|
|
||||||
function getReaderScrollMetrics() {
|
function getReaderScrollMetrics() {
|
||||||
@@ -2922,13 +3098,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return { scrollTop, maxScroll, pct, fraction };
|
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() {
|
function updateReaderProgressBar() {
|
||||||
const { pct } = getReaderScrollMetrics();
|
const { pct, fraction } = getReaderScrollMetrics();
|
||||||
if (readerChapterProgressFill) {
|
if (readerChapterProgressFill) {
|
||||||
readerChapterProgressFill.style.width = `${pct}%`;
|
readerChapterProgressFill.style.width = `${pct}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (readerScrollPctBadge) {
|
||||||
readerScrollPctBadge.textContent = `${pct}%`;
|
readerScrollPctBadge.textContent = `${pct}%`;
|
||||||
|
readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`;
|
||||||
|
}
|
||||||
|
} else if (readerScrollPctBadge) {
|
||||||
|
readerScrollPctBadge.textContent = `${pct}%`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2937,6 +3137,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updateReaderProgressBar();
|
updateReaderProgressBar();
|
||||||
|
|
||||||
const { scrollTop, fraction } = getReaderScrollMetrics();
|
const { scrollTop, fraction } = getReaderScrollMetrics();
|
||||||
|
const anchorIdx = getActiveAnchorIndex();
|
||||||
|
|
||||||
// 1. Instant local persistence
|
// 1. Instant local persistence
|
||||||
try {
|
try {
|
||||||
@@ -2945,6 +3146,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
chapter: currentChapterIndex,
|
chapter: currentChapterIndex,
|
||||||
scrollTop: scrollTop,
|
scrollTop: scrollTop,
|
||||||
fraction: fraction,
|
fraction: fraction,
|
||||||
|
anchorIdx: anchorIdx,
|
||||||
savedAt: Date.now()
|
savedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
localStorage.setItem('notes_active_book_id', currentBook.id);
|
localStorage.setItem('notes_active_book_id', currentBook.id);
|
||||||
@@ -2970,11 +3172,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
if (currentBook && readerScrollArea) {
|
if (currentBook && readerScrollArea) {
|
||||||
const { scrollTop, fraction } = getReaderScrollMetrics();
|
const { scrollTop, fraction } = getReaderScrollMetrics();
|
||||||
|
const anchorIdx = getActiveAnchorIndex();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({
|
localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({
|
||||||
chapter: currentChapterIndex,
|
chapter: currentChapterIndex,
|
||||||
scrollTop: scrollTop,
|
scrollTop: scrollTop,
|
||||||
fraction: fraction,
|
fraction: fraction,
|
||||||
|
anchorIdx: anchorIdx,
|
||||||
savedAt: Date.now()
|
savedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
localStorage.setItem('notes_active_book_id', currentBook.id);
|
localStorage.setItem('notes_active_book_id', currentBook.id);
|
||||||
@@ -3003,6 +3207,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
function applyReaderSettings() {
|
function applyReaderSettings() {
|
||||||
if (!readerScrollArea) return;
|
if (!readerScrollArea) return;
|
||||||
|
const anchorIdx = getActiveAnchorIndex();
|
||||||
|
|
||||||
// 1. Font size
|
// 1. Font size
|
||||||
readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`);
|
readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`);
|
||||||
@@ -3028,6 +3233,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`);
|
readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`);
|
||||||
|
|
||||||
updateReaderSettingsUI();
|
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() {
|
function updateReaderSettingsUI() {
|
||||||
@@ -3220,7 +3437,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
readerTrainBtn.addEventListener('click', () => {
|
readerTrainBtn.addEventListener('click', () => {
|
||||||
if (currentBook) {
|
if (currentBook) {
|
||||||
const bookTag = slugify(currentBook.title);
|
const bookTag = slugify(currentBook.title);
|
||||||
openSrsView(bookTag);
|
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3260,8 +3477,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const book = await resp.json();
|
const book = await resp.json();
|
||||||
addBookModal.classList.add('hidden');
|
addBookModal.classList.add('hidden');
|
||||||
await loadBookshelf();
|
window.location.hash = `#reader/book/${book.id}`;
|
||||||
openBook(book.id);
|
|
||||||
} else {
|
} else {
|
||||||
alert('Ошибка при сохранении книги');
|
alert('Ошибка при сохранении книги');
|
||||||
}
|
}
|
||||||
@@ -3288,8 +3504,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const book = await resp.json();
|
const book = await resp.json();
|
||||||
bookFileInput.value = '';
|
bookFileInput.value = '';
|
||||||
await loadBookshelf();
|
window.location.hash = `#reader/book/${book.id}`;
|
||||||
openBook(book.id);
|
|
||||||
} else {
|
} else {
|
||||||
const errData = await resp.json();
|
const errData = await resp.json();
|
||||||
alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`);
|
alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`);
|
||||||
|
|||||||
@@ -594,6 +594,29 @@ Nearly ten years had passed.
|
|||||||
assert "Chapter 1" in chs[0]["title"]
|
assert "Chapter 1" in chs[0]["title"]
|
||||||
assert "Chapter 2" in chs[1]["title"]
|
assert "Chapter 2" in chs[1]["title"]
|
||||||
|
|
||||||
|
# 3b. Smart chapter detection: word-spelled (CHAPTER ONE), numbered sections, ALL CAPS, bold
|
||||||
|
smart_txt = """Some intro notes before starting.
|
||||||
|
|
||||||
|
CHAPTER ONE: THE BEGINNING
|
||||||
|
It was a cold day in April.
|
||||||
|
|
||||||
|
1.1 Network Fundamentals
|
||||||
|
Devices communicate over IP.
|
||||||
|
|
||||||
|
**Key Architecture**
|
||||||
|
The architecture is modular.
|
||||||
|
|
||||||
|
CONCLUSION AND NEXT STEPS
|
||||||
|
Final thoughts on the topic.
|
||||||
|
"""
|
||||||
|
st_title, st_auth, st_chs = parse_text(smart_txt, title="Smart Book")
|
||||||
|
assert len(st_chs) >= 4
|
||||||
|
titles = [c["title"] for c in st_chs]
|
||||||
|
assert any("CHAPTER ONE" in t for t in titles)
|
||||||
|
assert any("1.1 Network Fundamentals" in t for t in titles)
|
||||||
|
assert any("Key Architecture" in t for t in titles)
|
||||||
|
assert any("CONCLUSION AND NEXT STEPS" in t for t in titles)
|
||||||
|
|
||||||
# 4. parse_text chunking on long text
|
# 4. parse_text chunking on long text
|
||||||
long_words = ["word"] * 7000
|
long_words = ["word"] * 7000
|
||||||
long_text = "\n\n".join([" ".join(long_words[i:i + 400]) for i in range(0, len(long_words), 400)])
|
long_text = "\n\n".join([" ".join(long_words[i:i + 400]) for i in range(0, len(long_words), 400)])
|
||||||
|
|||||||
Reference in New Issue
Block a user