2797 lines
120 KiB
JavaScript
2797 lines
120 KiB
JavaScript
// =======================================================================
|
||
// Notes Application - Reader & Bookshelf Module (Native ES Module)
|
||
// =======================================================================
|
||
|
||
import { api } from './api.js';
|
||
import { escapeHtml, escapeRegex, slugify, showToast } 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;
|
||
|
||
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() {
|
||
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(/·/g, '·')
|
||
.replace(/•/g, '•')
|
||
.replace(/—/g, '—')
|
||
.replace(/–/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 = '';
|
||
return;
|
||
}
|
||
const rect = targetElement.getBoundingClientRect();
|
||
const popupWidth = 320;
|
||
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 - 12) {
|
||
left = viewportWidth - popupWidth - 12;
|
||
}
|
||
if (left < 12) {
|
||
left = 12;
|
||
}
|
||
|
||
let top = rect.bottom + 8;
|
||
if (top + popupHeight > viewportHeight - 12) {
|
||
if (rect.top - popupHeight - 8 >= 65 || rect.top > viewportHeight / 2) {
|
||
top = Math.max(65, rect.top - popupHeight - 8);
|
||
}
|
||
}
|
||
|
||
readerWordPopup.style.top = `${Math.round(top)}px`;
|
||
readerWordPopup.style.left = `${Math.round(left)}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.add('hidden');
|
||
|
||
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}%`;
|
||
}
|
||
}
|
||
|
||
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`;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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 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 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 (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', () => {
|
||
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
|
||
});
|
||
});
|
||
}
|
||
}
|