Files
notes-qpid-pro/app/static/js/modules/reader.js
T

2988 lines
127 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =======================================================================
// Notes Application - Reader & Bookshelf Module (Native ES Module)
// =======================================================================
import { api } from './api.js';
import { escapeHtml, escapeRegex, slugify, showToast, playAudio, playTextSpeech } from './ui.js';
import { getAllCards, fetchCards } from './dictionary.js';
// Reader State & Appearance Settings
const DEFAULT_READER_SETTINGS = {
fontSize: 18,
fontFamily: 'serif',
lineHeight: 1.65,
marginWidth: 'normal',
theme: 'dark',
textAlign: 'left',
flowMode: 'chapters', // 'chapters' | 'continuous'
autoLoadNext: true // boolean: seamlessly load next chapter on scroll
};
let readerSettings = { ...DEFAULT_READER_SETTINGS };
let currentBook = null;
let currentChapterIndex = 0;
const loadedChaptersSet = new Set();
let isAppendingChapter = false;
let isHighlightEnabled = false;
let popupCurrentData = null;
let readerScrollSaveTimer = null;
let popupScrollRaf = null;
let isZenMode = false;
let zenHudIdleTimer = null;
export function getIsZenMode() {
return isZenMode;
}
export function getCurrentBook() {
return currentBook;
}
export function getCurrentChapterIndex() {
return currentChapterIndex;
}
// -----------------------------------------------------------------------
// Bookshelf Actions & Rendering
// -----------------------------------------------------------------------
function createBookCard(b) {
const card = document.createElement('div');
const isRfc = (b.source_type === 'rfc');
const isWiki = (b.source_type === 'wikipedia');
if (isWiki) card.className = 'book-card wiki-card';
else if (isRfc) card.className = 'book-card rfc-card';
else card.className = 'book-card';
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>`;
let coverHtml = '';
if (b.cover_url) {
coverHtml = `<div class="book-cover-img-wrap"><img src="${escapeHtml(b.cover_url)}" class="book-cover-img" alt="Cover" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\\'book-cover-icon ${isWiki ? 'wiki-cover-icon' : (isRfc ? 'rfc-cover-icon' : '')}\\\'>${isWiki ? '🌐' : (isRfc ? '📜' : '📄')}</div>';"/></div>`;
} else {
let coverIcon = '📄';
if (b.source_type === 'epub') coverIcon = '📕';
else if (b.source_type === 'pdf') coverIcon = '📑';
else if (isRfc) coverIcon = '📜';
else if (isWiki) coverIcon = '🌐';
const coverIconClass = isWiki ? 'wiki-cover-icon' : (isRfc ? 'rfc-cover-icon' : '');
coverHtml = `<div class="book-cover-icon ${coverIconClass}">${coverIcon}</div>`;
}
let badgeClass = 'badge-format';
if (isWiki) badgeClass = 'badge-format badge-wiki';
else if (isRfc) badgeClass = 'badge-format badge-rfc';
const readMinutes = Math.max(1, Math.round((b.total_words || 800) / 180));
const timeBadge = (b.total_words && b.total_words > 0)
? `<span class="badge-time" title="Примерное время чтения: ~${readMinutes} минут">⏱️ ~${readMinutes} мин</span>`
: '';
const progressFillClass = isWiki ? 'wiki-progress-fill' : (isRfc ? 'rfc-progress-fill' : '');
const authorDisplay = b.author || (isWiki ? 'Wikipedia' : (isRfc ? 'IETF' : 'Неизвестный автор'));
const sourceLabel = isWiki ? 'WIKIPEDIA' : (isRfc ? 'RFC' : (b.source_type || 'text').toUpperCase());
card.innerHTML = `
<div>
<div class="book-card-top">
${coverHtml}
<div class="book-card-info">
<h4 class="book-card-title" title="${escapeHtml(b.title)}">${escapeHtml(b.title)}</h4>
<p class="book-card-author">${escapeHtml(authorDisplay)}</p>
<div class="book-card-badges">
<span class="${badgeClass}">${escapeHtml(sourceLabel)}</span>
${timeBadge}
<span class="badge-words">${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}</span>
</div>
</div>
</div>
<div class="book-progress-wrap">
<div class="book-progress-labels">
${progressLabels}
</div>
<div class="book-progress-bar-bg">
<div class="book-progress-bar-fill ${progressFillClass}" style="width: ${totalPct}%"></div>
</div>
</div>
</div>
<div class="book-card-actions">
<button class="btn btn-primary btn-sm read-book-btn" data-id="${b.id}">📖 Читать</button>
<button class="btn btn-outline btn-xs train-book-btn" data-title="${escapeHtml(b.title)}" title="Тренировать слова из этой книги">🎓 Слова книги</button>
<button class="icon-btn delete-book-btn" data-id="${b.id}" title="Удалить из библиотеки">🗑️</button>
</div>
`;
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);
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
});
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
const typeName = isWiki ? 'статью Википедии' : (isRfc ? 'спецификацию' : 'книгу');
if (confirm(`Удалить ${typeName} "${b.title}" из библиотеки?`)) {
await api.books.delete(b.id);
loadBookshelf();
}
});
return card;
}
export async function loadBookshelf() {
if (isZenMode) toggleZenMode(false);
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
const bookshelfGrid = document.getElementById('bookshelf-grid');
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
const wikiGrid = document.getElementById('wiki-grid');
const wikiEmptyState = document.getElementById('wiki-empty-state');
if (!bookshelfContainer) return;
if (!window.location.hash.includes('/book/')) {
bookshelfContainer.classList.remove('hidden');
if (readerInterface) readerInterface.classList.add('hidden');
}
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
if (wikiGrid) wikiGrid.innerHTML = '';
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
if (rfcEmptyState) rfcEmptyState.classList.add('hidden');
if (wikiEmptyState) wikiEmptyState.classList.add('hidden');
try {
const books = await api.books.getAll();
renderBookshelf(books || []);
} catch (e) {
console.error('Error loading bookshelf:', e);
}
}
export function renderBookshelf(books) {
const bookshelfGrid = document.getElementById('bookshelf-grid');
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
const wikiGrid = document.getElementById('wiki-grid');
const wikiEmptyState = document.getElementById('wiki-empty-state');
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
if (wikiGrid) wikiGrid.innerHTML = '';
const generalBooks = (books || []).filter(b => b.source_type !== 'rfc' && b.source_type !== 'wikipedia');
const rfcBooks = (books || []).filter(b => b.source_type === 'rfc');
const wikiBooks = (books || []).filter(b => b.source_type === 'wikipedia');
if (bookshelfEmpty) {
bookshelfEmpty.classList.toggle('hidden', generalBooks.length > 0);
}
if (bookshelfGrid) {
generalBooks.forEach(b => {
bookshelfGrid.appendChild(createBookCard(b));
});
}
if (wikiEmptyState) {
wikiEmptyState.classList.toggle('hidden', wikiBooks.length > 0);
}
if (wikiGrid) {
wikiBooks.forEach(b => {
wikiGrid.appendChild(createBookCard(b));
});
}
if (rfcEmptyState) {
rfcEmptyState.classList.toggle('hidden', rfcBooks.length > 0);
}
if (rfcGrid) {
rfcBooks.forEach(b => {
rfcGrid.appendChild(createBookCard(b));
});
}
}
// -----------------------------------------------------------------------
// Open Book & Render Chapters
// -----------------------------------------------------------------------
export async function openBook(bookId, specificChapterIdx = null, updateHash = true) {
if (!bookId) return;
const readerView = document.getElementById('reader-view');
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
const readerBookTitle = document.getElementById('reader-book-title');
const readerBookAuthor = document.getElementById('reader-book-author');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerContentBody = document.getElementById('reader-content-body');
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');
// Update mobile navigation tabs
const navItems = document.querySelectorAll('.mobile-tab-btn');
navItems.forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === 'reader');
});
if (currentBook && currentBook.id === bookId) {
if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) {
if (specificChapterIdx !== currentChapterIndex) {
currentChapterIndex = specificChapterIdx;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
if (readerSettings.flowMode === 'continuous') {
const target = document.getElementById(`chapter-anchor-${specificChapterIdx}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${specificChapterIdx}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(specificChapterIdx);
}
} else {
renderChapter(specificChapterIdx, null, null, null);
}
}
}
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 bookData = await api.books.get(bookId);
currentBook = bookData;
// 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`);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.chapter === 'number') {
targetChapter = parsed.chapter;
targetScrollTop = parsed.scrollTop;
targetFraction = parsed.fraction;
targetAnchorIdx = parsed.anchorIdx;
}
}
} catch (e) {}
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)));
if (readerBookTitle) readerBookTitle.textContent = currentBook.title;
if (readerBookAuthor) readerBookAuthor.textContent = currentBook.author || '';
// Populate chapter selector with visual status markers
if (readerChapterSelect) {
readerChapterSelect.innerHTML = '';
const chapters = currentBook.chapters || [];
chapters.forEach((ch, idx) => {
const opt = document.createElement('option');
opt.value = idx;
const statusIcon = idx < currentChapterIndex ? '✔ ' : (idx === currentChapterIndex ? '▶ ' : '○ ');
opt.textContent = `${statusIcon}${ch.title || `Глава ${idx + 1}`}`;
readerChapterSelect.appendChild(opt);
});
}
applyReaderSettings();
try {
localStorage.setItem('notes_active_book_id', bookId);
localStorage.setItem('notes_active_view', 'reader-view');
} catch (e) {}
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);
alert('Не удалось открыть книгу');
window.location.hash = '#reader';
}
}
export function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false) {
if (!currentBook || !currentBook.chapters) return document.createDocumentFragment();
const chapter = currentBook.chapters[chapterIdx] || { title: '', content: '' };
const frag = document.createDocumentFragment();
if (chapterIdx > 0 || isContinuous) {
const divider = document.createElement('div');
divider.className = 'reader-chapter-divider';
divider.id = `chapter-anchor-${chapterIdx}`;
divider.setAttribute('data-chapter-idx', chapterIdx.toString());
const badge = document.createElement('span');
badge.className = 'divider-badge';
badge.textContent = `Глава ${chapterIdx + 1} из ${currentBook.chapters.length}`;
const titleEl = document.createElement('h2');
titleEl.className = 'divider-title';
titleEl.textContent = chapter.title || `Глава ${chapterIdx + 1}`;
divider.appendChild(badge);
divider.appendChild(titleEl);
frag.appendChild(divider);
} else {
const h = document.createElement('h2');
h.textContent = chapter.title || `Глава 1`;
h.setAttribute('data-block-idx', '0');
h.setAttribute('data-chapter-idx', '0');
h.id = `chapter-anchor-0`;
frag.appendChild(h);
}
const blocks = parseContentBlocks(chapter.content);
blocks.forEach((block, blockIdx) => {
const domIdx = blockIdx + 1;
let blockEl = null;
if (block.type === 'code') {
blockEl = document.createElement('div');
blockEl.className = 'reader-code-block';
const header = document.createElement('div');
header.className = 'reader-code-header';
const langSpan = document.createElement('span');
langSpan.className = 'reader-code-lang';
langSpan.textContent = block.lang || 'code';
const copyBtn = document.createElement('button');
copyBtn.className = 'reader-copy-code-btn';
copyBtn.type = 'button';
copyBtn.innerHTML = '📋 Копировать';
copyBtn.onclick = async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(block.code);
copyBtn.innerHTML = '✅ Скопировано!';
setTimeout(() => { copyBtn.innerHTML = '📋 Копировать'; }, 2000);
} catch (err) {
console.error('Copy failed:', err);
}
};
header.appendChild(langSpan);
header.appendChild(copyBtn);
const pre = document.createElement('pre');
const codeEl = document.createElement('code');
if (block.lang) codeEl.className = `language-${block.lang}`;
codeEl.textContent = block.code;
pre.appendChild(codeEl);
blockEl.appendChild(header);
blockEl.appendChild(pre);
} else if (block.type === 'figure') {
blockEl = document.createElement('figure');
blockEl.className = 'reader-figure';
const img = document.createElement('img');
img.className = 'reader-figure-img';
img.src = block.src;
img.alt = block.alt || '';
img.loading = 'lazy';
img.onerror = () => { blockEl.style.display = 'none'; };
blockEl.appendChild(img);
if (block.caption) {
const figcaption = document.createElement('figcaption');
figcaption.className = 'reader-figcaption';
renderInteractiveSpans(figcaption, block.caption, block.caption, knownWordsMap);
blockEl.appendChild(figcaption);
}
} else if (block.type === 'table') {
blockEl = document.createElement('div');
blockEl.className = 'reader-table-wrap';
const table = document.createElement('table');
table.className = 'reader-table';
if (block.headers && block.headers.length > 0) {
const thead = document.createElement('thead');
const headerTr = document.createElement('tr');
block.headers.forEach((hText, colIdx) => {
const th = document.createElement('th');
if (block.alignments && block.alignments[colIdx]) {
th.style.textAlign = block.alignments[colIdx];
}
renderInteractiveSpans(th, hText, hText, knownWordsMap);
headerTr.appendChild(th);
});
thead.appendChild(headerTr);
table.appendChild(thead);
}
if (block.rows && block.rows.length > 0) {
const tbody = document.createElement('tbody');
block.rows.forEach(row => {
const tr = document.createElement('tr');
row.forEach((cellText, colIdx) => {
const td = document.createElement('td');
if (block.alignments && block.alignments[colIdx]) {
td.style.textAlign = block.alignments[colIdx];
}
renderInteractiveSpans(td, cellText, cellText, knownWordsMap);
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
}
blockEl.appendChild(table);
} else if (block.type === 'heading') {
const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`;
blockEl = document.createElement(tag);
if (block.align) blockEl.style.textAlign = block.align;
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
} else if (block.type === 'blockquote') {
blockEl = document.createElement('blockquote');
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
} else if (block.type === 'ul' || block.type === 'ol') {
blockEl = document.createElement(block.type);
block.items.forEach(itemText => {
const li = document.createElement('li');
renderInteractiveSpans(li, itemText, itemText, knownWordsMap);
blockEl.appendChild(li);
});
} else if (block.type === 'hr') {
blockEl = document.createElement('hr');
} else {
blockEl = document.createElement('p');
if (block.align) {
blockEl.style.textAlign = block.align;
blockEl.classList.add('text-center');
}
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
}
if (blockEl) {
blockEl.setAttribute('data-block-idx', domIdx.toString());
blockEl.setAttribute('data-chapter-idx', chapterIdx.toString());
frag.appendChild(blockEl);
}
});
return frag;
}
export function buildChapterFooterDOM(chapterIdx) {
if (!currentBook || !currentBook.chapters) return null;
const totalCh = currentBook.chapters.length;
const isLastChapter = (chapterIdx >= totalCh - 1);
const footer = document.createElement('div');
footer.className = 'reader-chapter-footer';
footer.id = `reader-chapter-footer-${chapterIdx}`;
footer.setAttribute('data-footer-for-ch', chapterIdx.toString());
if (isLastChapter) {
footer.classList.add('reader-book-finished');
footer.innerHTML = `
<div class="chapter-footer-status">
<span class="status-check">🎉</span>
<strong style="color: var(--primary); font-size: 16px;">Книга прочитана!</strong>
</div>
<p class="chapter-footer-hint">Поздравляем! Вы завершили чтение «${escapeHtml(currentBook.title)}».</p>
<div class="chapter-footer-nav">
<button type="button" class="btn btn-trainer btn-sm reader-footer-nav-btn" id="footer-train-btn-${chapterIdx}">
🎓 Тренировать слова книги
</button>
<button type="button" class="btn btn-outline btn-sm reader-footer-nav-btn" id="footer-shelf-btn-${chapterIdx}">
📚 К библиотеке
</button>
</div>
`;
const trainBtn = footer.querySelector(`#footer-train-btn-${chapterIdx}`);
if (trainBtn) {
trainBtn.onclick = () => {
const tag = slugify(currentBook.title);
window.location.hash = `#srs/${encodeURIComponent(tag)}`;
};
}
const shelfBtn = footer.querySelector(`#footer-shelf-btn-${chapterIdx}`);
if (shelfBtn) {
shelfBtn.onclick = () => {
window.location.hash = '#reader';
};
}
return footer;
}
const nextCh = chapterIdx + 1;
const nextTitle = currentBook.chapters[nextCh]?.title || `Глава ${nextCh + 1}`;
footer.innerHTML = `
<div class="chapter-footer-status">
<span class="status-check">✔</span>
<span>Глава ${chapterIdx + 1} завершена</span>
</div>
<div class="chapter-footer-nav">
${chapterIdx > 0 ? `
<button type="button" class="btn btn-outline btn-sm reader-footer-nav-btn prev" id="footer-prev-btn-${chapterIdx}">
◀ Назад
</button>` : ''}
<button type="button" class="btn btn-primary btn-sm reader-footer-nav-btn next" id="footer-next-btn-${chapterIdx}">
Следующая: ${escapeHtml(nextTitle)} ▶
</button>
</div>
<div class="chapter-footer-hint">
${readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext
? '⚡ Автоподгрузка включена: следующая глава подгрузится автоматически при скролле вниз'
: '💡 Нажмите кнопку выше для перехода к следующей главе'}
</div>
`;
const nextBtn = footer.querySelector(`#footer-next-btn-${chapterIdx}`);
if (nextBtn) {
nextBtn.onclick = () => {
if (readerSettings.flowMode === 'continuous') {
const target = document.getElementById(`chapter-anchor-${nextCh}`);
if (target) scrollToElement(target, true);
} else if (readerSettings.autoLoadNext) {
appendNextChapter(nextCh, true);
} else {
window.location.hash = `#reader/book/${currentBook.id}/ch/${nextCh}`;
}
};
}
const prevBtn = footer.querySelector(`#footer-prev-btn-${chapterIdx}`);
if (prevBtn) {
prevBtn.onclick = () => {
window.location.hash = `#reader/book/${currentBook.id}/ch/${chapterIdx - 1}`;
};
}
return footer;
}
export function scrollToElement(targetEl, smooth = true) {
const readerScrollArea = document.getElementById('reader-scroll-area');
if (!targetEl || !readerScrollArea) return;
const containerTop = readerScrollArea.getBoundingClientRect().top;
const elTop = targetEl.getBoundingClientRect().top;
const targetScroll = readerScrollArea.scrollTop + (elTop - containerTop - 10);
readerScrollArea.scrollTo({
top: Math.max(0, targetScroll),
behavior: smooth ? 'smooth' : 'auto'
});
}
export function updateChapterSelectIcons() {
const readerChapterSelect = document.getElementById('reader-chapter-select');
if (!readerChapterSelect || !readerChapterSelect.options || !currentBook || !currentBook.chapters) return;
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}`;
}
}
export function appendNextChapter(nextCh, smoothScroll = false) {
if (!currentBook || !currentBook.chapters || nextCh >= currentBook.chapters.length) return;
const readerContentBody = document.getElementById('reader-content-body');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
if (!readerContentBody) return;
if (loadedChaptersSet.has(nextCh)) {
if (smoothScroll) {
const target = document.getElementById(`chapter-anchor-${nextCh}`);
if (target) scrollToElement(target, true);
}
return;
}
const oldFooter = readerContentBody.querySelector(`.reader-chapter-footer[data-footer-for-ch="${nextCh - 1}"]`);
if (oldFooter) {
oldFooter.remove();
}
const knownWordsMap = new Map();
const allCards = getAllCards() || [];
allCards.forEach(c => {
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
});
const chFrag = buildChapterDOM(nextCh, knownWordsMap, true);
readerContentBody.appendChild(chFrag);
const newFooter = buildChapterFooterDOM(nextCh);
if (newFooter) {
readerContentBody.appendChild(newFooter);
}
loadedChaptersSet.add(nextCh);
if (readerPrevChBtn) readerPrevChBtn.disabled = false;
if (readerNextChBtn) readerNextChBtn.disabled = nextCh >= currentBook.chapters.length - 1;
if (smoothScroll) {
const anchor = document.getElementById(`chapter-anchor-${nextCh}`);
if (anchor) {
scrollToElement(anchor, true);
}
}
}
export function renderContinuousBook(targetChapter = 0, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) {
if (!currentBook || !currentBook.chapters) return;
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerContentBody = document.getElementById('reader-content-body');
const readerScrollArea = document.getElementById('reader-scroll-area');
currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1));
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
if (!readerContentBody) return;
readerContentBody.innerHTML = '';
loadedChaptersSet.clear();
const knownWordsMap = new Map();
const allCards = getAllCards() || [];
allCards.forEach(c => {
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
});
const totalChapters = currentBook.chapters.length;
const docFrag = document.createDocumentFragment();
for (let ch = 0; ch < totalChapters; ch++) {
loadedChaptersSet.add(ch);
const chFrag = buildChapterDOM(ch, knownWordsMap, ch > 0);
docFrag.appendChild(chFrag);
}
const finishFooter = buildChapterFooterDOM(totalChapters - 1);
if (finishFooter) {
docFrag.appendChild(finishFooter);
}
readerContentBody.appendChild(docFrag);
// Apply scroll: either restored from storage/arguments or to target chapter anchor
if (restoreScrollTop !== null || restoreFraction !== null || restoreAnchorIdx !== null) {
applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex);
} else if (currentChapterIndex > 0) {
requestAnimationFrame(() => {
const targetAnchor = document.getElementById(`chapter-anchor-${currentChapterIndex}`);
if (targetAnchor) {
scrollToElement(targetAnchor, false);
}
updateReaderProgressBar();
});
} else {
if (readerScrollArea) readerScrollArea.scrollTop = 0;
updateReaderProgressBar();
}
const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0;
api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: posToSave })
.catch(err => console.log('Progress save error:', err));
}
export function renderChapter(index, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) {
if (!currentBook || !currentBook.chapters) return;
if (readerSettings.flowMode === 'continuous') {
renderContinuousBook(index, restoreScrollTop, restoreFraction, restoreAnchorIdx);
return;
}
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerContentBody = document.getElementById('reader-content-body');
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
if (!readerContentBody) return;
readerContentBody.innerHTML = '';
loadedChaptersSet.clear();
loadedChaptersSet.add(currentChapterIndex);
const knownWordsMap = new Map();
const allCards = getAllCards() || [];
allCards.forEach(c => {
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
});
const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false);
readerContentBody.appendChild(chFrag);
const footer = buildChapterFooterDOM(currentChapterIndex);
if (footer) {
readerContentBody.appendChild(footer);
}
applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex);
// Save progress to server
const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0;
api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: posToSave })
.catch(err => console.log('Progress save error:', err));
}
export function applyScroll(restoreAnchorIdx = null, restoreScrollTop = null, restoreFraction = null, chIdx = 0) {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea) return;
function doScroll() {
if (restoreAnchorIdx !== null && restoreAnchorIdx !== undefined) {
const el = (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${chIdx}"][data-block-idx="${restoreAnchorIdx}"]`))
|| (readerContentBody && 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) {
const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight);
readerScrollArea.scrollTop = Math.round(maxScroll * restoreFraction);
} else if (chIdx > 0) {
const anchor = document.getElementById(`chapter-anchor-${chIdx}`);
if (anchor) {
const containerTop = readerScrollArea.getBoundingClientRect().top;
const elTop = anchor.getBoundingClientRect().top;
readerScrollArea.scrollTop += (elTop - containerTop - 10);
} else {
readerScrollArea.scrollTop = 0;
}
} else {
readerScrollArea.scrollTop = 0;
}
updateReaderProgressBar();
}
requestAnimationFrame(() => {
doScroll();
setTimeout(doScroll, 100);
});
}
// -----------------------------------------------------------------------
// Content Block Parsing & Interactive Spans
// -----------------------------------------------------------------------
export function parseContentBlocks(content) {
if (!content) return [];
const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
const blocks = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// 1. Fenced Code Block: ```lang ... ```
if (trimmed.startsWith('```')) {
const lang = trimmed.slice(3).trim() || 'code';
const codeLines = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length && lines[i].trim().startsWith('```')) {
i++; // skip closing ```
}
blocks.push({
type: 'code',
lang: lang,
code: codeLines.join('\n')
});
continue;
}
// 2. Empty line
if (!trimmed) {
i++;
continue;
}
// 2a. HTML Figure Block: <figure ...> ... </figure>
if (/^<figure\b/i.test(trimmed)) {
const figLines = [];
while (i < lines.length) {
figLines.push(lines[i]);
if (/<\/figure>/i.test(lines[i])) {
i++;
break;
}
i++;
}
const figHtml = figLines.join('\n');
const imgMatch = figHtml.match(/<img\b([^>]*)>/i);
let src = '';
let alt = '';
if (imgMatch) {
const srcM = imgMatch[1].match(/src=["']([^"']+)["']/i);
const altM = imgMatch[1].match(/alt=["']([^"']*)["']/i);
if (srcM) src = srcM[1];
if (altM) alt = altM[1];
}
let caption = '';
const capMatch = figHtml.match(/<figcaption\b[^>]*>([\s\S]*?)<\/figcaption>/i);
if (capMatch) {
caption = capMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
if (src) {
blocks.push({
type: 'figure',
src: src,
alt: alt,
caption: caption
});
continue;
}
}
// 2b. HTML Data Table: <div class="reader-table-wrap"><table ...> or <table ...>
if (/^(?:<div[^>]*class=["'][^"']*reader-table-wrap[^"']*["'][^>]*>\s*)?<table\b/i.test(trimmed)) {
const tableLines = [];
while (i < lines.length) {
tableLines.push(lines[i]);
if (/<\/table>(?:\s*<\/div>)?/i.test(lines[i])) {
i++;
break;
}
i++;
}
const fullTableHtml = tableLines.join('\n');
const tpl = document.createElement('template');
tpl.innerHTML = fullTableHtml;
const tableEl = tpl.content.querySelector('table');
if (tableEl) {
const headers = [];
const ths = tableEl.querySelectorAll('thead th, tr:first-child th');
ths.forEach(th => headers.push(th.textContent.trim()));
const rows = [];
const trs = tableEl.querySelectorAll('tbody tr, tr');
trs.forEach((tr) => {
const tds = tr.querySelectorAll('td');
if (tds.length > 0) {
const rowCells = [];
tds.forEach(td => rowCells.push(td.textContent.trim()));
rows.push(rowCells);
}
});
if (headers.length > 0 || rows.length > 0) {
blocks.push({
type: 'table',
headers: headers,
alignments: headers.map(() => 'left'),
rows: rows
});
continue;
}
}
}
// 3. Markdown Table: lines starting with | and ending with |
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
const tableLines = [];
while (i < lines.length && lines[i].trim().startsWith('|')) {
tableLines.push(lines[i].trim());
i++;
}
if (tableLines.length >= 2) {
const splitRow = (rowStr) => {
const inner = rowStr.replace(/^\|/, '').replace(/\|$/, '');
return inner.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
};
const headers = splitRow(tableLines[0]);
const alignRow = splitRow(tableLines[1]);
const alignments = alignRow.map(col => {
const tc = col.trim();
if (tc.startsWith(':') && tc.endsWith(':')) return 'center';
if (tc.endsWith(':')) return 'right';
return 'left';
});
const rows = [];
for (let r = 2; r < tableLines.length; r++) {
rows.push(splitRow(tableLines[r]));
}
blocks.push({
type: 'table',
headers: headers,
alignments: alignments,
rows: rows
});
continue;
}
}
// 4. Markdown Headings: #, ##, ###, ####, #####, ######
const headingMatch = line.match(/^(\#{1,6})\s+(.+)$/);
if (headingMatch) {
blocks.push({
type: 'heading',
level: Math.min(6, Math.max(1, headingMatch[1].length)),
text: headingMatch[2].trim()
});
i++;
continue;
}
// HTML Heading: <h1...>...</h1>
const htmlHeadingMatch = line.match(/^<h([1-6])([^>]*)>(.*?)<\/h\1>/i);
if (htmlHeadingMatch) {
const level = parseInt(htmlHeadingMatch[1], 10);
const attrs = htmlHeadingMatch[2] || '';
const hText = htmlHeadingMatch[3].trim();
const align = /align=["']?center["']?/i.test(attrs) ? 'center' : '';
blocks.push({
type: 'heading',
level: level,
text: hText,
align: align
});
i++;
continue;
}
// 5. Horizontal Rule: --- or *** or ___ or <hr>
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '<hr>' || trimmed === '<hr/>' || trimmed === '<hr />') {
blocks.push({ type: 'hr' });
i++;
continue;
}
// 6. Blockquote: > ...
if (trimmed.startsWith('>')) {
const quoteLines = [];
while (i < lines.length && lines[i].trim().startsWith('>')) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''));
i++;
}
blocks.push({
type: 'blockquote',
text: quoteLines.join(' ')
});
continue;
}
// 7. Unordered List: - item or * item or + item
if (/^[-*+]\s+/.test(trimmed)) {
const listItems = [];
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
listItems.push(lines[i].trim().replace(/^[-*+]\s+/, ''));
i++;
}
blocks.push({
type: 'ul',
items: listItems
});
continue;
}
// 8. Ordered List: 1. item
if (/^\d+\.\s+/.test(trimmed)) {
const listItems = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
listItems.push(lines[i].trim().replace(/^\d+\.\s+/, ''));
i++;
}
blocks.push({
type: 'ol',
items: listItems
});
continue;
}
// 9. Centered HTML block (<p align="center"> or <div align="center">)
if (/^<(?:p|div)[^>]*align=["']?center["']?[^>]*>/i.test(trimmed)) {
const htmlLines = [];
while (i < lines.length) {
const cur = lines[i];
htmlLines.push(cur.trim());
if (/<\/(?:p|div)>/i.test(cur)) {
i++;
break;
}
i++;
}
blocks.push({
type: 'paragraph',
text: htmlLines.join(' '),
align: 'center'
});
continue;
}
// 10. Regular paragraph: gather lines until next block marker or empty line
const paraLines = [];
while (i < lines.length) {
const cur = lines[i];
const curTrim = cur.trim();
if (!curTrim) break;
if (curTrim.startsWith('```') ||
/^#{1,6}\s+/.test(curTrim) ||
/^<h[1-6][^>]*>/i.test(curTrim) ||
(curTrim.startsWith('|') && curTrim.endsWith('|')) ||
curTrim.startsWith('>') ||
/^[-*+]\s+/.test(curTrim) ||
/^\d+\.\s+/.test(curTrim) ||
/^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) ||
/^<figure\b/i.test(curTrim) ||
/^<table\b/i.test(curTrim) ||
curTrim.includes('reader-table-wrap') ||
/^<(?:p|div)[^>]*align=["']?center/i.test(curTrim)) {
break;
}
paraLines.push(curTrim);
i++;
}
if (paraLines.length > 0) {
blocks.push({
type: 'paragraph',
text: paraLines.join(' ')
});
}
}
return blocks;
}
export function renderInteractiveSpans(container, text, contextText, knownWordsMap) {
if (!text) return;
let processed = text
.replace(/&middot;/g, '·')
.replace(/&bull;/g, '•')
.replace(/&mdash;/g, '—')
.replace(/&ndash;/g, '–');
processed = processed.replace(/^<p[^>]*>/i, '').replace(/<\/p>$/i, '');
processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, '');
// Convert Markdown inline patterns into HTML
processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
processed = processed.replace(/`([^`]+)`/g, '<code class="reader-inline-code">$1</code>');
processed = processed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
processed = processed.replace(/__([^_]+)__/g, '<strong>$1</strong>');
processed = processed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
processed = processed.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
const tpl = document.createElement('template');
tpl.innerHTML = processed;
function transformDOMNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const raw = node.textContent;
if (!raw) return document.createTextNode('');
const frag = document.createDocumentFragment();
const tokens = raw.split(/([A-Za-z0-9'-]+)/);
for (const token of tokens) {
const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1;
if (isWord) {
const span = document.createElement('span');
span.className = 'reader-word';
span.dataset.word = token;
span.dataset.context = contextText || raw;
span.textContent = token;
const lower = token.toLowerCase();
if (knownWordsMap && knownWordsMap.has(lower)) {
const card = knownWordsMap.get(lower);
span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
}
if (isHighlightEnabled) {
span.classList.add('highlight-enabled');
}
frag.appendChild(span);
} else {
frag.appendChild(document.createTextNode(token));
}
}
return frag;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
if (tag === 'code' || tag === 'pre') {
const codeEl = document.createElement('code');
codeEl.className = 'reader-inline-code';
codeEl.textContent = node.textContent;
return codeEl;
}
if (tag === 'img') {
const img = document.createElement('img');
img.className = 'reader-img';
img.src = node.getAttribute('src') || '';
img.alt = node.getAttribute('alt') || '';
if (node.getAttribute('width')) img.setAttribute('width', node.getAttribute('width'));
if (node.getAttribute('height')) img.setAttribute('height', node.getAttribute('height'));
img.addEventListener('error', () => {
img.style.display = 'none';
});
return img;
}
if (tag === 'a') {
const a = document.createElement('a');
a.className = 'reader-link';
a.href = node.getAttribute('href') || '#';
a.target = '_blank';
a.rel = 'noopener noreferrer';
Array.from(node.childNodes).forEach(child => {
a.appendChild(transformDOMNode(child));
});
return a;
}
if (tag === 'br') {
return document.createElement('br');
}
if (tag === 'strong' || tag === 'b') {
const strong = document.createElement('strong');
Array.from(node.childNodes).forEach(child => {
strong.appendChild(transformDOMNode(child));
});
return strong;
}
if (tag === 'em' || tag === 'i') {
const em = document.createElement('em');
Array.from(node.childNodes).forEach(child => {
em.appendChild(transformDOMNode(child));
});
return em;
}
if (tag === 'sub' || tag === 'sup') {
const el = document.createElement(tag);
Array.from(node.childNodes).forEach(child => {
el.appendChild(transformDOMNode(child));
});
return el;
}
const containerEl = document.createElement(tag === 'p' ? 'span' : tag);
if (node.hasAttribute('style')) {
containerEl.setAttribute('style', node.getAttribute('style'));
}
Array.from(node.childNodes).forEach(child => {
containerEl.appendChild(transformDOMNode(child));
});
return containerEl;
}
return document.createTextNode('');
}
Array.from(tpl.content.childNodes).forEach(childNode => {
container.appendChild(transformDOMNode(childNode));
});
}
export function robustSentenceSplit(text) {
if (!text) return [];
let s = text;
// Step 1: Protect decimal numbers (e.g. 3.14, 1.5)
s = s.replace(/(\d+)\.(\d+)/g, '$1§DOT§$2');
// Step 2: Protect acronyms with dots like U.S., U.K., e.g., i.e., a.m., p.m., Ph.D.
s = s.replace(/\b((?:[A-Za-z]\.){2,})/g, (m) => m.replace(/\./g, '§DOT§'));
// Step 3: Protect single-letter initials like 'John F. Kennedy', 'J. K. Rowling'
s = s.replace(/\b([A-Za-z])\.(?=\s+[A-Za-z0-9"“'‘«—])/g, '$1§DOT§');
// Step 4: Protect common honorifics, titles, and abbreviations
const abbrPattern = /\b(Mr|Mrs|Ms|Miss|Dr|Prof|Rev|Hon|Capt|Col|Gen|Lt|Cmdr|Sgt|Cpl|Pvt|Maj|Adm|Gov|Sen|Rep|Pres|Sec|Treas|Insp|Det|Supt|Messrs|Mmes|St|Mt|Ft|etc|vs|v|Jr|Sr|approx|app|apt|dept|univ|assn|assoc|bldg|co|corp|inc|ltd|est|misc|al|ibid|op|cit|ed|eds|trans|vol|vols|no|nos|sec|secs|ch|chs|fig|figs|p|pp|ave|blvd|rd|st|ct|ln|dr|hwy|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\./gi;
s = s.replace(abbrPattern, '$1§DOT§');
// Step 5: Protect ellipsis (... or ....)
s = s.replace(/\.{2,}/g, (m) => '§ELLIPSIS§' + '§DOT§'.repeat(m.length));
// Step 6: Protect any period followed by lowercase letter (e.g. 'word. otherword')
s = s.replace(/\.([\'"”’»)]*\s+[a-zа-яё])/g, '§DOT§$1');
// Step 7: Split on sentence terminators [.!?] followed by whitespace and uppercase/number/quote/start of dialog
const marked = s.replace(/([.!?]['"”’»)]*)(\s+)(?=[A-Z0-9"“'‘«—]|$)/g, '$1§SEP§');
return marked.split('§SEP§')
.map(part => part.replace(/§DOT§/g, '.').replace(/§ELLIPSIS§/g, '').trim())
.filter(Boolean);
}
export function extractContextSentence(fullText, word) {
if (!fullText) return '';
const sentences = robustSentenceSplit(fullText);
const cleanWord = (word || '').trim();
if (cleanWord) {
const escaped = escapeRegex(cleanWord);
const wordRe = new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i');
for (const s of sentences) {
if (wordRe.test(s)) {
return s.replace(/\s+/g, ' ').trim();
}
}
}
return sentences.length > 0 ? sentences[0].replace(/\s+/g, ' ').trim() : fullText.slice(0, 160).trim();
}
// -----------------------------------------------------------------------
// Interactive Word Popup
// -----------------------------------------------------------------------
export function positionWordPopup(targetElement) {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!targetElement || !readerWordPopup) return;
if (window.innerWidth <= 768) {
readerWordPopup.style.top = '';
readerWordPopup.style.left = '';
readerWordPopup.style.right = '';
readerWordPopup.style.bottom = '';
readerWordPopup.style.width = '';
return;
}
const rect = targetElement.getBoundingClientRect();
const popupWidth = (readerWordPopup.offsetWidth && readerWordPopup.offsetWidth >= 280 && readerWordPopup.offsetWidth <= 380)
? readerWordPopup.offsetWidth
: 340;
const popupHeight = readerWordPopup.offsetHeight || 220;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = rect.left + (rect.width / 2) - (popupWidth / 2);
if (left + popupWidth > viewportWidth - 16) {
left = viewportWidth - popupWidth - 16;
}
if (left < 16) {
left = 16;
}
const minTop = isZenMode ? 16 : 65;
let top = rect.bottom + 8;
if (top + popupHeight > viewportHeight - 16) {
if (rect.top - popupHeight - 8 >= minTop || rect.top > viewportHeight / 2) {
top = Math.max(minTop, rect.top - popupHeight - 8);
}
}
readerWordPopup.style.top = `${Math.round(top)}px`;
readerWordPopup.style.left = `${Math.round(left)}px`;
readerWordPopup.style.right = 'auto';
readerWordPopup.style.bottom = 'auto';
readerWordPopup.style.width = `${popupWidth}px`;
}
export async function openWordPopup(term, contextSentence, targetElement, paragraphContext = '') {
const readerWordPopup = document.getElementById('reader-word-popup');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const popupTerm = document.getElementById('popup-term');
const popupPhonetic = document.getElementById('popup-phonetic');
const popupTranslation = document.getElementById('popup-translation');
const popupContext = document.getElementById('popup-context');
const popupLoading = document.getElementById('popup-loading');
const popupContent = document.getElementById('popup-content');
const popupInLibrary = document.getElementById('popup-in-library');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
const popupAudioBtn = document.getElementById('popup-audio-btn');
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
if (!cleanTerm || !readerWordPopup) return;
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
const lowerClean = cleanTerm.toLowerCase();
const allCards = getAllCards() || [];
const existingCard = allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean);
popupCurrentData = {
term: cleanTerm,
contextSentence: contextSentence,
paragraphContext: paragraphContext,
targetElement: targetElement,
existingCard: existingCard,
};
if (popupTerm) popupTerm.textContent = cleanTerm;
if (popupPhonetic) popupPhonetic.textContent = '';
if (popupTranslation) popupTranslation.textContent = '';
if (popupContext) popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
if (popupLoading) popupLoading.classList.remove('hidden');
if (popupContent) popupContent.classList.add('hidden');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar');
const popupPhrasalCard = document.getElementById('popup-phrasal-card');
const popupPhrasalTerm = document.getElementById('popup-phrasal-term');
const popupPhrasalRaw = document.getElementById('popup-phrasal-raw');
const popupPhrasalTranslation = document.getElementById('popup-phrasal-translation');
const popupPhrasalLibraryBadge = document.getElementById('popup-phrasal-library-badge');
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
popupCurrentData.phrasalVerb = null;
popupCurrentData.isPhrasalMode = false;
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = '';
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = '';
if (popupAiSourceBadge) popupAiSourceBadge.classList.add('hidden');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.disabled = false;
popupTranslateSentenceBtn.textContent = '🌐 Перевести предложение';
if (popupSentenceAiBar) popupSentenceAiBar.classList.toggle('hidden', !contextSentence);
}
if (popupInLibrary) {
popupInLibrary.classList.toggle('hidden', !existingCard);
}
if (existingCard) {
if (popupSaveBtn) popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
if (popupAddContextBtn) {
if (contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else {
popupAddContextBtn.classList.add('hidden');
}
}
} else {
if (popupSavedBadge) popupSavedBadge.classList.add('hidden');
if (popupSaveBtn) {
popupSaveBtn.classList.remove('hidden');
popupSaveBtn.disabled = false;
}
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
readerWordPopup.classList.remove('hidden');
positionWordPopup(targetElement);
try {
const data = await api.lookup.search(cleanTerm, contextSentence);
popupCurrentData.lookup = data;
// Phrasal verb detection
if (data.phrasal_verb) {
const pv = data.phrasal_verb;
popupCurrentData.phrasalVerb = pv;
if (popupPhrasalCard) {
popupPhrasalCard.classList.remove('hidden');
if (popupPhrasalTerm) popupPhrasalTerm.textContent = pv.term;
if (popupPhrasalRaw) popupPhrasalRaw.textContent = `(в тексте: «${pv.raw}»)`;
if (popupPhrasalTranslation) popupPhrasalTranslation.textContent = pv.translation;
if (popupPhrasalLibraryBadge) popupPhrasalLibraryBadge.classList.toggle('hidden', !pv.in_library);
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
}
} else {
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
}
const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : '');
if (popupPhonetic) popupPhonetic.textContent = data.phonetic || '';
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
if (popupLoading) popupLoading.classList.add('hidden');
if (popupContent) popupContent.classList.remove('hidden');
if (popupAudioBtn) {
popupAudioBtn.classList.remove('hidden');
popupAudioBtn.style.display = 'inline-flex';
}
popupCurrentData.audioUrl = data.audio_url || '';
const inLib = Boolean(data.in_library || existingCard);
const libCard = data.library_card || existingCard;
if (inLib) {
popupCurrentData.existingCard = libCard;
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
if (popupSaveBtn) popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
if (popupAddContextBtn) {
if (contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else {
popupAddContextBtn.classList.add('hidden');
}
}
} else {
if (popupInLibrary) popupInLibrary.classList.add('hidden');
if (popupSaveBtn) popupSaveBtn.classList.remove('hidden');
if (popupSavedBadge) popupSavedBadge.classList.add('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
positionWordPopup(targetElement);
} catch (e) {
console.error('Popup lookup error:', e);
if (popupTranslation) popupTranslation.textContent = 'Сетевая ошибка';
if (popupLoading) popupLoading.classList.add('hidden');
if (popupContent) popupContent.classList.remove('hidden');
positionWordPopup(targetElement);
}
}
export async function handleWordClick(word, paragraphText, targetElement) {
const sentence = extractContextSentence(paragraphText, word);
openWordPopup(word, sentence, targetElement, paragraphText);
}
function updatePopupPositionOnScroll() {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (window.innerWidth <= 768) return;
if (!popupCurrentData || !popupCurrentData.targetElement || !popupCurrentData.targetElement.isConnected) {
readerWordPopup.classList.add('hidden');
return;
}
const rect = popupCurrentData.targetElement.getBoundingClientRect();
const headerOffset = 65;
if (rect.bottom < headerOffset || rect.top > window.innerHeight) {
readerWordPopup.classList.add('hidden');
return;
}
positionWordPopup(popupCurrentData.targetElement);
}
function schedulePopupUpdate() {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (popupScrollRaf) return;
popupScrollRaf = requestAnimationFrame(() => {
popupScrollRaf = null;
updatePopupPositionOnScroll();
});
}
// -----------------------------------------------------------------------
// Scroll Position Tracking & Metrics
// -----------------------------------------------------------------------
export function getReaderScrollMetrics() {
const readerScrollArea = document.getElementById('reader-scroll-area');
if (!readerScrollArea) return { scrollTop: 0, maxScroll: 0, pct: 0, fraction: 0 };
const scrollTop = readerScrollArea.scrollTop;
const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight);
const fraction = maxScroll > 0 ? Math.min(1.0, Math.max(0, scrollTop / maxScroll)) : 0;
const pct = Math.min(100, Math.max(0, Math.round(fraction * 100)));
return { scrollTop, maxScroll, pct, fraction };
}
export function getActiveAnchorIndex() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
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;
}
export function getActiveVisibleChapter() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea || !readerContentBody) return currentChapterIndex;
const anchors = readerContentBody.querySelectorAll('[id^="chapter-anchor-"]');
if (!anchors || anchors.length === 0) return currentChapterIndex;
const containerTop = readerScrollArea.getBoundingClientRect().top;
let activeIdx = 0;
for (let el of anchors) {
const rect = el.getBoundingClientRect();
if (rect.top <= containerTop + 140) {
const ch = parseInt(el.getAttribute('data-chapter-idx'), 10);
if (!isNaN(ch)) activeIdx = ch;
} else {
break;
}
}
return activeIdx;
}
export function updateReaderProgressBar() {
const readerChapterProgressFill = document.getElementById('reader-chapter-progress-fill');
const readerScrollPctBadge = document.getElementById('reader-scroll-pct-badge');
const { pct, fraction } = getReaderScrollMetrics();
if (readerSettings.flowMode === 'continuous') {
if (readerChapterProgressFill) {
readerChapterProgressFill.style.width = `${pct}%`;
}
if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
const totalCh = currentBook && currentBook.chapters ? currentBook.chapters.length : 1;
readerScrollPctBadge.title = `Вся книга: ${pct}% • Глава ${currentChapterIndex + 1} из ${totalCh}`;
}
return;
}
if (readerChapterProgressFill) {
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) {
readerScrollPctBadge.textContent = `${pct}%`;
readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`;
}
} else if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
}
// Update Zen HUD elements if present
const zenHudTitle = document.getElementById('zen-hud-title');
const zenHudChapter = document.getElementById('zen-hud-chapter');
const zenHudProgress = document.getElementById('zen-hud-progress');
if (zenHudTitle && currentBook) {
zenHudTitle.textContent = currentBook.title || 'Книга';
zenHudTitle.title = currentBook.title || 'Книга';
}
if (zenHudChapter && currentBook && currentBook.chapters) {
const totalCh = currentBook.chapters.length;
const ch = currentBook.chapters[currentChapterIndex];
const chTitle = ch ? (ch.title || `Глава ${currentChapterIndex + 1}`) : `Глава ${currentChapterIndex + 1}`;
zenHudChapter.textContent = totalCh > 1 ? `Гл. ${currentChapterIndex + 1}/${totalCh}` : chTitle;
zenHudChapter.title = chTitle;
}
if (zenHudProgress) {
zenHudProgress.textContent = `${pct}%`;
}
}
export function handleReaderScroll() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
if (!currentBook || !readerScrollArea) return;
updateReaderProgressBar();
// 1. Detect active visible chapter in continuous or multi-chapter view
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.size > 1) {
const activeCh = getActiveVisibleChapter();
if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) {
currentChapterIndex = activeCh;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const desiredHash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
}
}
// 2. Infinite scroll: auto-append next chapter in chapters mode with autoload
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) {
const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight);
if (distanceToBottom < 700) {
const loadedArr = Array.from(loadedChaptersSet);
const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex;
if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) {
isAppendingChapter = true;
appendNextChapter(highestCh + 1, false);
setTimeout(() => { isAppendingChapter = false; }, 200);
}
}
}
const { scrollTop, fraction } = getReaderScrollMetrics();
const anchorIdx = getActiveAnchorIndex();
// 3. Instant local persistence
try {
const key = `notes_book_${currentBook.id}_pos`;
localStorage.setItem(key, JSON.stringify({
chapter: currentChapterIndex,
scrollTop: scrollTop,
fraction: fraction,
anchorIdx: anchorIdx,
savedAt: Date.now()
}));
localStorage.setItem('notes_active_book_id', currentBook.id);
localStorage.setItem('notes_active_view', 'reader-view');
} catch (e) {}
// 4. Debounced server sync (every 800ms)
clearTimeout(readerScrollSaveTimer);
readerScrollSaveTimer = setTimeout(() => {
if (!currentBook) return;
api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: fraction })
.catch(err => console.log('Progress save error:', err));
}, 800);
}
// -----------------------------------------------------------------------
// Reader Appearance & Settings
// -----------------------------------------------------------------------
export function loadSavedReaderSettings() {
try {
const saved = localStorage.getItem('notes_reader_settings');
if (saved) {
readerSettings = Object.assign({}, DEFAULT_READER_SETTINGS, JSON.parse(saved));
}
} catch (e) {}
}
export function saveReaderSettings() {
try {
localStorage.setItem('notes_reader_settings', JSON.stringify(readerSettings));
} catch (e) {}
}
export function applyReaderSettings() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const settingFontSizeVal = document.getElementById('setting-font-size-val');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea) return;
const anchorIdx = getActiveAnchorIndex();
// 1. Font size
readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`);
if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`;
// 2. Line height
readerScrollArea.style.setProperty('--reader-line-height', readerSettings.lineHeight);
// 3. Theme
readerScrollArea.classList.remove('reader-theme-dark', 'reader-theme-sepia', 'reader-theme-light', 'reader-theme-oled');
readerScrollArea.classList.add(`reader-theme-${readerSettings.theme}`);
// 4. Font family
readerScrollArea.classList.remove('reader-font-sans', 'reader-font-serif', 'reader-font-mono');
readerScrollArea.classList.add(`reader-font-${readerSettings.fontFamily}`);
// 5. Margin width
readerScrollArea.classList.remove('reader-margins-narrow', 'reader-margins-normal', 'reader-margins-wide');
readerScrollArea.classList.add(`reader-margins-${readerSettings.marginWidth}`);
// 6. Text align
readerScrollArea.classList.remove('reader-align-left', 'reader-align-justify');
readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`);
updateReaderSettingsUI();
// Re-anchor scroll position after font/layout change
if (anchorIdx !== null && anchorIdx !== undefined && readerContentBody) {
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);
}
});
}
}
export function updateReaderSettingsUI() {
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const settingFontSizeVal = document.getElementById('setting-font-size-val');
const readerFontFamilyGroup = document.getElementById('reader-font-family-group');
const readerLineHeightGroup = document.getElementById('reader-line-height-group');
const readerMarginsGroup = document.getElementById('reader-margins-group');
const readerThemeGroup = document.getElementById('reader-theme-group');
const readerAlignGroup = document.getElementById('reader-align-group');
const readerModeGroup = document.getElementById('reader-mode-group');
const readerAutoloadGroup = document.getElementById('reader-autoload-group');
const settingAutoloadRow = document.getElementById('setting-autoload-row');
if (!readerSettingsPanel) return;
if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`;
if (readerFontFamilyGroup) {
readerFontFamilyGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.font === readerSettings.fontFamily);
});
}
if (readerLineHeightGroup) {
readerLineHeightGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', Math.abs(parseFloat(btn.dataset.lh) - parseFloat(readerSettings.lineHeight)) < 0.05);
});
}
if (readerMarginsGroup) {
readerMarginsGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.margin === readerSettings.marginWidth);
});
}
if (readerThemeGroup) {
readerThemeGroup.querySelectorAll('.theme-chip').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === readerSettings.theme);
});
}
if (readerAlignGroup) {
readerAlignGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === readerSettings.textAlign);
});
}
if (readerModeGroup) {
readerModeGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.flow === readerSettings.flowMode);
});
}
if (settingAutoloadRow) {
settingAutoloadRow.style.display = (readerSettings.flowMode === 'chapters') ? '' : 'none';
}
if (readerAutoloadGroup) {
readerAutoloadGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', (btn.dataset.autoload === 'true') === readerSettings.autoLoadNext);
});
}
}
export function positionSettingsPanel() {
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const readerSettingsBtn = document.getElementById('reader-settings-btn');
if (!readerSettingsPanel || !readerSettingsBtn) return;
if (window.innerWidth <= 768) {
readerSettingsPanel.style.top = '';
readerSettingsPanel.style.left = '';
readerSettingsPanel.style.right = '';
readerSettingsPanel.style.maxHeight = '';
return;
}
const rect = readerSettingsBtn.getBoundingClientRect();
const panelWidth = 330;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = rect.right - panelWidth;
if (left < 16) left = 16;
if (left + panelWidth > viewportWidth - 16) {
left = viewportWidth - panelWidth - 16;
}
const top = Math.round(rect.bottom + 8);
const maxH = Math.max(260, viewportHeight - top - 16);
readerSettingsPanel.style.top = `${top}px`;
readerSettingsPanel.style.left = `${Math.round(left)}px`;
readerSettingsPanel.style.right = 'auto';
readerSettingsPanel.style.maxHeight = `${maxH}px`;
}
// -----------------------------------------------------------------------
// Zen Focus Mode
// -----------------------------------------------------------------------
export function wakeZenHud() {
if (!isZenMode) return;
const hud = document.getElementById('reader-zen-hud');
if (!hud) return;
hud.classList.remove('hud-idle');
if (zenHudIdleTimer) {
clearTimeout(zenHudIdleTimer);
}
zenHudIdleTimer = setTimeout(() => {
if (isZenMode && hud) {
hud.classList.add('hud-idle');
}
}, 3000);
}
export function toggleZenMode(forceState = null) {
const newState = (forceState !== null) ? Boolean(forceState) : !isZenMode;
if (isZenMode === newState) return;
isZenMode = newState;
const hud = document.getElementById('reader-zen-hud');
const zenBtn = document.getElementById('reader-zen-btn');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
if (isZenMode) {
document.body.classList.add('zen-mode-active');
if (zenBtn) zenBtn.classList.add('active');
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
if (hud) {
hud.classList.remove('hidden');
hud.classList.remove('hud-idle');
wakeZenHud();
}
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
} else {
document.body.classList.remove('zen-mode-active');
if (zenBtn) zenBtn.classList.remove('active');
if (hud) {
hud.classList.add('hidden');
hud.classList.remove('hud-idle');
}
if (zenHudIdleTimer) {
clearTimeout(zenHudIdleTimer);
zenHudIdleTimer = null;
}
if (document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
}
}
// -----------------------------------------------------------------------
// Reader Module Initialization
// -----------------------------------------------------------------------
export function initReader() {
loadSavedReaderSettings();
applyReaderSettings();
const readerSettingsBtn = document.getElementById('reader-settings-btn');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const readerSettingsCloseBtn = document.getElementById('reader-settings-close-btn');
const readerWordPopup = document.getElementById('reader-word-popup');
const settingFontDecBtn = document.getElementById('setting-font-dec-btn');
const settingFontIncBtn = document.getElementById('setting-font-inc-btn');
const readerFontFamilyGroup = document.getElementById('reader-font-family-group');
const readerLineHeightGroup = document.getElementById('reader-line-height-group');
const readerMarginsGroup = document.getElementById('reader-margins-group');
const readerThemeGroup = document.getElementById('reader-theme-group');
const readerAlignGroup = document.getElementById('reader-align-group');
const readerModeGroup = document.getElementById('reader-mode-group');
const readerAutoloadGroup = document.getElementById('reader-autoload-group');
const readerContentBody = document.getElementById('reader-content-body');
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerBackBtn = document.getElementById('reader-back-btn');
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
const readerTrainBtn = document.getElementById('reader-train-btn');
const readerZenBtn = document.getElementById('reader-zen-btn');
const zenHudExitBtn = document.getElementById('zen-hud-exit-btn');
if (readerZenBtn) {
readerZenBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleZenMode();
});
}
if (zenHudExitBtn) {
zenHudExitBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleZenMode(false);
});
}
// Wake HUD on user activity during zen mode
['mousemove', 'touchstart', 'scroll'].forEach(evt => {
window.addEventListener(evt, () => {
if (isZenMode) wakeZenHud();
}, { passive: true });
});
// Keyboard shortcut for Zen Mode (F / f / а / А) and exit on Escape
document.addEventListener('keydown', (e) => {
const tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
if (tag === 'input' || tag === 'textarea' || (e.target && e.target.isContentEditable)) {
return;
}
const readerView = document.getElementById('reader-view');
const readerInterface = document.getElementById('reader-interface');
const isReaderActive = readerView && !readerView.classList.contains('hidden') &&
readerInterface && !readerInterface.classList.contains('hidden');
if (!isReaderActive) return;
if (e.key === 'Escape' && isZenMode) {
e.preventDefault();
toggleZenMode(false);
return;
}
if ((e.key === 'f' || e.key === 'F' || e.key === 'а' || e.key === 'А') && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
toggleZenMode();
}
});
// Native fullscreen sync
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement && isZenMode) {
toggleZenMode(false);
}
});
const popupCloseBtn = document.getElementById('popup-close-btn');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupInLibrary = document.getElementById('popup-in-library');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
const popupTranslation = document.getElementById('popup-translation');
const addTextBtn = document.getElementById('add-text-btn');
const addBookModal = document.getElementById('add-book-modal');
const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn');
const cancelAddBookBtn = document.getElementById('cancel-add-book-btn');
const addBookForm = document.getElementById('add-book-form');
const bookTitleInput = document.getElementById('book-title-input');
const bookAuthorInput = document.getElementById('book-author-input');
const bookContentInput = document.getElementById('book-content-input');
const bookFileInput = document.getElementById('book-file-input');
// Settings panel triggers
if (readerSettingsBtn) {
readerSettingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (readerWordPopup) readerWordPopup.classList.add('hidden');
if (readerSettingsPanel) {
const isHidden = readerSettingsPanel.classList.contains('hidden');
readerSettingsPanel.classList.toggle('hidden', !isHidden);
if (isHidden) {
updateReaderSettingsUI();
positionSettingsPanel();
}
}
});
}
if (readerSettingsCloseBtn) {
readerSettingsCloseBtn.addEventListener('click', () => {
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
});
}
if (settingFontDecBtn) {
settingFontDecBtn.addEventListener('click', () => {
if (readerSettings.fontSize > 13) {
readerSettings.fontSize -= 1;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (settingFontIncBtn) {
settingFontIncBtn.addEventListener('click', () => {
if (readerSettings.fontSize < 34) {
readerSettings.fontSize += 1;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerFontFamilyGroup) {
readerFontFamilyGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.font) {
readerSettings.fontFamily = btn.dataset.font;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerLineHeightGroup) {
readerLineHeightGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.lh) {
readerSettings.lineHeight = parseFloat(btn.dataset.lh);
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerMarginsGroup) {
readerMarginsGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.margin) {
readerSettings.marginWidth = btn.dataset.margin;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerThemeGroup) {
readerThemeGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.theme-chip');
if (btn && btn.dataset.theme) {
readerSettings.theme = btn.dataset.theme;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerAlignGroup) {
readerAlignGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.align) {
readerSettings.textAlign = btn.dataset.align;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerModeGroup) {
readerModeGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.flow) {
if (readerSettings.flowMode !== btn.dataset.flow) {
readerSettings.flowMode = btn.dataset.flow;
saveReaderSettings();
updateReaderSettingsUI();
if (currentBook) {
if (readerSettings.flowMode === 'continuous') {
renderContinuousBook(currentChapterIndex);
} else {
renderChapter(currentChapterIndex);
}
}
}
}
});
}
if (readerAutoloadGroup) {
readerAutoloadGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.autoload !== undefined) {
readerSettings.autoLoadNext = (btn.dataset.autoload === 'true');
saveReaderSettings();
updateReaderSettingsUI();
const footerHint = readerContentBody ? readerContentBody.querySelector('.chapter-footer-hint') : null;
if (footerHint) {
footerHint.textContent = (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)
? '⚡ Автоподгрузка включена: следующая глава подгрузится автоматически при скролле вниз'
: '💡 Нажмите кнопку выше для перехода к следующей главе';
}
}
});
}
document.addEventListener('click', (e) => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) {
if (!readerSettingsPanel.contains(e.target) && e.target !== readerSettingsBtn && !readerSettingsBtn.contains(e.target)) {
readerSettingsPanel.classList.add('hidden');
}
}
});
window.addEventListener('resize', () => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) {
positionSettingsPanel();
}
}, { passive: true });
window.addEventListener('scroll', () => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden') && window.innerWidth > 768) {
positionSettingsPanel();
}
}, { passive: true });
// Word clicking & highlighting
if (readerContentBody) {
readerContentBody.addEventListener('click', (e) => {
const wordSpan = e.target.closest('.reader-word');
if (wordSpan) {
e.stopPropagation();
const word = wordSpan.dataset.word;
const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote, figcaption')?.textContent || word;
handleWordClick(word, context, wordSpan);
}
});
// Phrase/chunk selection in reader
readerContentBody.addEventListener('mouseup', () => {
const sel = window.getSelection();
const text = sel.toString().trim();
if (text && text.split(/\s+/).length >= 2 && text.length <= 80) {
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
openWordPopup(text, text, {
getBoundingClientRect: () => rect,
});
}
});
}
if (readerToggleHighlightBtn) {
readerToggleHighlightBtn.addEventListener('click', () => {
isHighlightEnabled = !isHighlightEnabled;
readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled);
if (readerContentBody) {
readerContentBody.querySelectorAll('.reader-word').forEach(w => {
w.classList.toggle('highlight-enabled', isHighlightEnabled);
});
}
});
}
if (readerTrainBtn) {
readerTrainBtn.addEventListener('click', () => {
if (currentBook) {
const bookTag = slugify(currentBook.title);
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
}
});
}
// Popup actions
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', () => {
if (readerWordPopup) readerWordPopup.classList.add('hidden');
});
}
document.addEventListener('click', (e) => {
if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) {
readerWordPopup.classList.add('hidden');
}
});
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
const popupAudioSentenceSlowBtn = document.getElementById('popup-audio-sentence-slow-btn');
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
if (popupAudioBtn) {
popupAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.term) return;
playTextSpeech(popupCurrentData.term, 'en');
});
}
if (popupAudioSentenceBtn) {
popupAudioSentenceBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
playTextSpeech(popupCurrentData.contextSentence, 'en', 1.0);
});
}
if (popupAudioSentenceSlowBtn) {
popupAudioSentenceSlowBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
playTextSpeech(popupCurrentData.contextSentence, 'en', 0.75);
});
}
if (popupAudioRuBtn) {
popupAudioRuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
playTextSpeech(ruText, 'ru', 1.0);
});
}
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const ctxSentence = popupCurrentData.contextSentence;
const term = popupCurrentData.term;
popupTranslateSentenceBtn.disabled = true;
if (popupSentenceLoading) popupSentenceLoading.classList.remove('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
try {
const res = await api.lookup.translateSentence(ctxSentence, term, popupCurrentData.paragraphContext || '');
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) {
popupSentenceRu.textContent = res.sentence_ru || 'Перевод не получен';
}
if (res.word_meaning && res.word_meaning.trim()) {
popupCurrentData.contextMeaning = res.word_meaning.trim();
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.remove('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = res.word_meaning.trim();
} else {
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
}
if (popupAiSourceBadge) {
popupAiSourceBadge.classList.remove('hidden');
popupAiSourceBadge.textContent = res.cached ? '⚡ Кеш (0.01ms)' : '⚡ Local AI (Qwen)';
popupAiSourceBadge.title = res.source || '';
}
positionWordPopup(popupCurrentData.targetElement);
} catch (err) {
console.error('Sentence translation error:', err);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = 'Ошибка связи с локальным ИИ';
} finally {
popupTranslateSentenceBtn.disabled = false;
}
});
}
if (popupUseContextMeaningBtn) {
popupUseContextMeaningBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextMeaning) return;
const meaning = popupCurrentData.contextMeaning;
if (popupTranslation) popupTranslation.textContent = meaning;
if (popupCurrentData.lookup) {
popupCurrentData.lookup.translation = meaning;
}
popupUseContextMeaningBtn.textContent = '✓ Вставлено!';
setTimeout(() => {
if (popupUseContextMeaningBtn) popupUseContextMeaningBtn.textContent = 'Вставить в карточку';
}, 1800);
});
}
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.phrasalVerb) return;
const pv = popupCurrentData.phrasalVerb;
if (!popupCurrentData.isPhrasalMode) {
// Switch to phrasal verb mode
popupCurrentData.isPhrasalMode = true;
if (popupTerm) popupTerm.textContent = pv.term;
if (popupTranslation) popupTranslation.textContent = pv.translation;
if (popupPhonetic) popupPhonetic.textContent = '';
const inLib = Boolean(pv.in_library);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = `💾 Сохранить «${pv.term}» в словарь`;
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Фразовый глагол уже в библиотеке';
}
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
popupSwitchPhrasalBtn.textContent = `↩️ Вернуться к «${popupCurrentData.term}»`;
} else {
// Revert back to single word
popupCurrentData.isPhrasalMode = false;
const origTrans = popupCurrentData.lookup ? (popupCurrentData.lookup.translation || popupCurrentData.lookup.translation_ru || '') : '';
if (popupTerm) popupTerm.textContent = popupCurrentData.term;
if (popupTranslation) popupTranslation.textContent = origTrans;
if (popupPhonetic) popupPhonetic.textContent = popupCurrentData.lookup?.phonetic || '';
const inLib = Boolean(popupCurrentData.lookup?.in_library || popupCurrentData.existingCard);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = '💾 Добавить в словарь';
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
positionWordPopup(popupCurrentData.targetElement);
});
}
if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const isPv = Boolean(d.isPhrasalMode && d.phrasalVerb);
const termToSave = isPv ? d.phrasalVerb.term : d.term;
const transToSave = isPv
? d.phrasalVerb.translation
: (lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''));
const collectionToSave = isPv ? 'Фразовые глаголы & Идиомы' : 'Книги & Сериалы';
const tagsToSave = isPv ? `${bookTag}, phrasal-verbs, reader` : `${bookTag}, reader`;
const payload = {
term: termToSave,
translation: transToSave,
context_sentence: d.contextSentence || '',
phonetic: isPv ? '' : (lookup.phonetic || ''),
audio_url: isPv ? '' : (lookup.audio_url || ''),
part_of_speech: isPv ? 'verb phrase' : (lookup.part_of_speech || ''),
definition_en: lookup.definition_en || '',
collection: collectionToSave,
tags: tagsToSave,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
try {
const resp = await api.cards.create(payload);
if (resp.ok) {
const resData = await resp.json();
popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = resData.already_exists ? '⭐ Уже есть в библиотеке' : '✅ Сохранено в словарь!';
}
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
if (d.targetElement) {
d.targetElement.classList.add('status-learning');
if (isHighlightEnabled) d.targetElement.classList.add('highlight-enabled');
}
await fetchCards();
} else {
alert('Не удалось сохранить слово');
}
} catch (e) {
console.error('Save card from reader error:', e);
}
});
}
if (popupAddContextBtn) {
popupAddContextBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const payload = {
term: d.term,
translation: lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''),
context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '',
audio_url: lookup.audio_url || '',
part_of_speech: lookup.part_of_speech || '',
definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы',
tags: `${bookTag}, reader`,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
popupAddContextBtn.disabled = true;
popupAddContextBtn.textContent = 'Сохраняем...';
try {
const resp = await api.cards.create(payload);
if (resp.ok) {
popupAddContextBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!';
popupSavedBadge.classList.remove('hidden');
}
await fetchCards();
} else {
alert('Не удалось обновить контекст');
}
} catch (e) {
console.error('Add context error:', e);
} finally {
popupAddContextBtn.disabled = false;
popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке';
}
});
}
// Chapter Navigation & Reader Toolbar
if (readerChapterSelect) {
readerChapterSelect.addEventListener('change', () => {
const ch = parseInt(readerChapterSelect.value, 10);
if (!currentBook) return;
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = ch;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${ch}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${ch}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(ch);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${ch}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`;
});
}
if (readerPrevChBtn) {
readerPrevChBtn.addEventListener('click', () => {
if (currentBook && currentChapterIndex > 0) {
const prev = currentChapterIndex - 1;
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = prev;
if (readerChapterSelect) readerChapterSelect.value = prev;
updateChapterSelectIcons();
readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${prev}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${prev}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(prev);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${prev}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
window.location.hash = `#reader/book/${currentBook.id}/ch/${prev}`;
}
});
}
if (readerNextChBtn) {
readerNextChBtn.addEventListener('click', () => {
if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) {
const next = currentChapterIndex + 1;
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = next;
if (readerChapterSelect) readerChapterSelect.value = next;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${next}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${next}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(next);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${next}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) {
appendNextChapter(next, true);
} else {
window.location.hash = `#reader/book/${currentBook.id}/ch/${next}`;
}
}
});
}
if (readerBackBtn) {
readerBackBtn.addEventListener('click', () => {
if (isZenMode) toggleZenMode(false);
window.location.hash = '#reader';
});
}
if (readerScrollArea) {
readerScrollArea.addEventListener('scroll', handleReaderScroll, { passive: true });
}
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);
} catch (e) {}
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify({ current_chapter: currentChapterIndex, current_position: fraction })], { type: 'application/json' });
navigator.sendBeacon(`/api/books/${currentBook.id}/progress`, blob);
}
}
});
// Bookshelf Add/Upload Actions
if (addTextBtn && addBookModal && addBookForm) {
addTextBtn.addEventListener('click', () => {
addBookForm.reset();
addBookModal.classList.remove('hidden');
});
}
if (closeAddBookModalBtn && addBookModal) {
closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
}
if (cancelAddBookBtn && addBookModal) {
cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
}
if (addBookForm) {
addBookForm.addEventListener('submit', async (e) => {
e.preventDefault();
const title = bookTitleInput ? bookTitleInput.value.trim() : '';
const author = bookAuthorInput ? bookAuthorInput.value.trim() : '';
const content = bookContentInput ? bookContentInput.value.trim() : '';
if (!title || !content) return;
try {
const resp = await api.books.createManual({
title: title,
author: author,
content: content,
source_type: 'text',
});
if (resp.ok) {
const book = await resp.json();
if (addBookModal) addBookModal.classList.add('hidden');
window.location.hash = `#reader/book/${book.id}`;
} else {
alert('Ошибка при сохранении книги');
}
} catch (err) {
console.error('Book create error:', err);
}
});
}
if (bookFileInput) {
bookFileInput.addEventListener('change', async () => {
const file = bookFileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await api.books.uploadFile(formData);
if (resp.ok) {
const book = await resp.json();
bookFileInput.value = '';
window.location.hash = `#reader/book/${book.id}`;
} else {
let errMsg = 'Не удалось обработать файл';
try {
const errData = await resp.json();
errMsg = errData.detail || errMsg;
} catch (_) {
if (resp.status === 413) {
errMsg = 'Файл слишком большой (превышен лимит размера в Nginx)';
} else {
errMsg = `Код ответа сервера: HTTP ${resp.status}`;
}
}
alert(`Ошибка загрузки: ${errMsg}`);
}
} catch (e) {
console.error('File upload error:', e);
alert('Ошибка сети или соединение прервано при загрузке файла');
}
});
}
// RFC Fetching & Presets
const rfcFetchForm = document.getElementById('rfc-fetch-form');
const rfcInput = document.getElementById('rfc-input');
const rfcFetchBtn = document.getElementById('rfc-fetch-btn');
const rfcFetchStatus = document.getElementById('rfc-fetch-status');
const rfcPresetChips = document.querySelectorAll('.rfc-preset-chip');
async function handleRfcFetch(query) {
if (!query) return;
query = query.trim();
if (!query) return;
if (rfcFetchBtn) rfcFetchBtn.disabled = true;
if (rfcInput) rfcInput.disabled = true;
if (rfcFetchStatus) {
rfcFetchStatus.className = 'rfc-fetch-status rfc-status-loading';
rfcFetchStatus.innerHTML = `<span>⏳ Загрузка и разбор спецификации RFC...</span>`;
rfcFetchStatus.classList.remove('hidden');
}
try {
const book = await api.books.fetchRfc(query);
if (rfcFetchStatus) {
rfcFetchStatus.className = 'rfc-fetch-status rfc-status-success';
rfcFetchStatus.innerHTML = `<span>✅ ${escapeHtml(book.title)} успешно добавлен!</span>`;
}
showToast(book.already_existed ? `Открываем ${book.title}` : `RFC спецификация загружена!`);
if (rfcInput) rfcInput.value = '';
setTimeout(() => {
if (rfcFetchStatus) rfcFetchStatus.classList.add('hidden');
window.location.hash = `#reader/book/${book.id}`;
}, 400);
} catch (err) {
console.error('RFC fetch error:', err);
if (rfcFetchStatus) {
rfcFetchStatus.className = 'rfc-fetch-status rfc-status-error';
rfcFetchStatus.innerHTML = `<span>❌ Ошибка: ${escapeHtml(err.message || 'Не удалось загрузить RFC')}</span>`;
rfcFetchStatus.classList.remove('hidden');
}
showToast(err.message || 'Ошибка загрузки RFC', 4000);
} finally {
if (rfcFetchBtn) rfcFetchBtn.disabled = false;
if (rfcInput) {
rfcInput.disabled = false;
rfcInput.focus();
}
}
}
if (rfcFetchForm) {
rfcFetchForm.addEventListener('submit', (e) => {
e.preventDefault();
const val = rfcInput ? rfcInput.value : '';
handleRfcFetch(val);
});
}
if (rfcPresetChips && rfcPresetChips.length > 0) {
rfcPresetChips.forEach(chip => {
chip.addEventListener('click', () => {
const rfcVal = chip.dataset.rfc;
if (rfcInput) rfcInput.value = rfcVal;
handleRfcFetch(rfcVal);
});
});
}
// -----------------------------------------------------------------------
// Wikipedia Autonomous Import & Settings
// -----------------------------------------------------------------------
const WIKI_SETTINGS_KEY = 'notes_wiki_settings';
const wikiReadBtn = document.getElementById('wiki-read-btn');
const wikiSettingsBtn = document.getElementById('wiki-settings-btn');
const wikiSettingsModal = document.getElementById('wiki-settings-modal');
const wikiSettingsCloseBtn = document.getElementById('wiki-settings-close-btn');
const wikiSettingsSaveBtn = document.getElementById('wiki-settings-save-btn');
const wikiFetchStatus = document.getElementById('wiki-fetch-status');
const wikiCustomForm = document.getElementById('wiki-custom-form');
const wikiCustomInput = document.getElementById('wiki-custom-input');
const wikiCustomFetchBtn = document.getElementById('wiki-custom-fetch-btn');
const wikiActiveLangBadge = document.getElementById('wiki-active-lang-badge');
const wikiActiveTimeBadge = document.getElementById('wiki-active-time-badge');
const wikiActiveTopicBadge = document.getElementById('wiki-active-topic-badge');
const TOPIC_LABELS = {
'random': '🎲 Любая тема',
'science': '🚀 Наука и космос',
'tech': '💻 Технологии и IT',
'history': '🏛️ История',
'nature': '🌿 Природа',
'psychology': '🧠 Психология',
'featured': '⭐ Статья дня'
};
const TIME_LABELS = {
'5': '⏱️ ~5 мин',
'10-15': '⏱️ ~10-15 мин',
'20+': '⏱️ 20+ мин',
'any': '⏱️ Любой объем'
};
function getStoredWikiSettings() {
try {
const raw = localStorage.getItem(WIKI_SETTINGS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
lang: parsed.lang || 'simple',
time_budget: parsed.time_budget || '10-15',
topic: parsed.topic || 'random'
};
}
}
} catch (_) {}
return { lang: 'simple', time_budget: '10-15', topic: 'random' };
}
function saveStoredWikiSettings(settings) {
try {
localStorage.setItem(WIKI_SETTINGS_KEY, JSON.stringify(settings));
} catch (_) {}
updateWikiUIBadges(settings);
}
function updateWikiUIBadges(settings) {
if (wikiActiveLangBadge) {
wikiActiveLangBadge.textContent = settings.lang === 'en' ? 'Standard English (B2–C1)' : 'Simple English (A2–B1)';
}
if (wikiActiveTimeBadge) {
wikiActiveTimeBadge.textContent = TIME_LABELS[settings.time_budget] || '⏱️ ~10-15 мин';
}
if (wikiActiveTopicBadge) {
wikiActiveTopicBadge.textContent = TOPIC_LABELS[settings.topic] || '🎲 Любая тема';
}
}
function syncWikiModalInputs(settings) {
const langRadios = document.querySelectorAll('input[name="wiki-lang"]');
langRadios.forEach(r => {
r.checked = (r.value === settings.lang);
});
const timeRadios = document.querySelectorAll('input[name="wiki-time"]');
timeRadios.forEach(r => {
r.checked = (r.value === settings.time_budget);
});
const topicSelect = document.getElementById('wiki-topic-select');
if (topicSelect) {
topicSelect.value = settings.topic || 'random';
}
}
function readWikiModalInputs() {
const langRadio = document.querySelector('input[name="wiki-lang"]:checked');
const timeRadio = document.querySelector('input[name="wiki-time"]:checked');
const topicSelect = document.getElementById('wiki-topic-select');
return {
lang: langRadio ? langRadio.value : 'simple',
time_budget: timeRadio ? timeRadio.value : '10-15',
topic: topicSelect ? topicSelect.value : 'random'
};
}
const currentWikiSettings = getStoredWikiSettings();
updateWikiUIBadges(currentWikiSettings);
if (wikiSettingsBtn && wikiSettingsModal) {
wikiSettingsBtn.addEventListener('click', () => {
syncWikiModalInputs(getStoredWikiSettings());
wikiSettingsModal.classList.remove('hidden');
});
}
if (wikiSettingsCloseBtn && wikiSettingsModal) {
wikiSettingsCloseBtn.addEventListener('click', () => {
wikiSettingsModal.classList.add('hidden');
});
}
if (wikiSettingsModal) {
wikiSettingsModal.addEventListener('click', (e) => {
if (e.target === wikiSettingsModal) {
wikiSettingsModal.classList.add('hidden');
}
});
}
if (wikiSettingsSaveBtn && wikiSettingsModal) {
wikiSettingsSaveBtn.addEventListener('click', () => {
const newSettings = readWikiModalInputs();
saveStoredWikiSettings(newSettings);
wikiSettingsModal.classList.add('hidden');
showToast('Настройки статей Википедии сохранены!');
});
}
async function handleWikiFetch(params) {
const isCustom = Boolean(params.query);
const activeBtn = isCustom ? wikiCustomFetchBtn : wikiReadBtn;
if (activeBtn) activeBtn.disabled = true;
if (wikiReadBtn) wikiReadBtn.disabled = true;
if (wikiCustomInput) wikiCustomInput.disabled = true;
if (wikiFetchStatus) {
wikiFetchStatus.className = 'wiki-fetch-status wiki-status-loading';
wikiFetchStatus.innerHTML = `<span>⏳ ${isCustom ? 'Загрузка статьи по запросу...' : 'Подбираем и загружаем статью из Википедии...'}</span>`;
wikiFetchStatus.classList.remove('hidden');
}
try {
const book = await api.books.fetchWikipedia(params);
if (wikiFetchStatus) {
wikiFetchStatus.className = 'wiki-fetch-status wiki-status-success';
const estMin = Math.max(1, Math.round((book.total_words || 800) / 180));
wikiFetchStatus.innerHTML = `<span>✅ Статья «${escapeHtml(book.title)}» сохранена (~${estMin} мин, ${book.total_words ? book.total_words.toLocaleString() : ''} слов)!</span>`;
}
showToast(book.already_existed ? `Открываем ${book.title}` : `Статья Википедии сохранена офлайн!`);
if (wikiCustomInput) wikiCustomInput.value = '';
await loadBookshelf();
setTimeout(() => {
if (wikiFetchStatus) wikiFetchStatus.classList.add('hidden');
if (wikiSettingsModal) wikiSettingsModal.classList.add('hidden');
window.location.hash = `#reader/book/${book.id}`;
}, 500);
} catch (err) {
console.error('Wikipedia fetch error:', err);
if (wikiFetchStatus) {
wikiFetchStatus.className = 'wiki-fetch-status wiki-status-error';
wikiFetchStatus.innerHTML = `<span>❌ Ошибка: ${escapeHtml(err.message || 'Не удалось загрузить статью')}</span>`;
wikiFetchStatus.classList.remove('hidden');
}
showToast(err.message || 'Ошибка загрузки статьи Wikipedia', 4000);
} finally {
if (activeBtn) activeBtn.disabled = false;
if (wikiReadBtn) wikiReadBtn.disabled = false;
if (wikiCustomInput) wikiCustomInput.disabled = false;
}
}
if (wikiReadBtn) {
wikiReadBtn.addEventListener('click', () => {
const currentSettings = getStoredWikiSettings();
handleWikiFetch({
query: '',
lang: currentSettings.lang,
time_budget: currentSettings.time_budget,
topic: currentSettings.topic,
force: false
});
});
}
if (wikiCustomForm) {
wikiCustomForm.addEventListener('submit', (e) => {
e.preventDefault();
const val = wikiCustomInput ? wikiCustomInput.value.trim() : '';
if (!val) return;
const currentSettings = getStoredWikiSettings();
handleWikiFetch({
query: val,
lang: currentSettings.lang,
time_budget: currentSettings.time_budget,
topic: currentSettings.topic,
force: false
});
});
}
}