// ======================================================================= // 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'); card.className = isRfc ? 'book-card rfc-card' : '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 ? `1 глава • ${totalPct}%${totalPct}%` : `Глава ${chIndex + 1} из ${totalCh} (${chPct}%)${totalPct}%`; let coverIcon = '📄'; if (b.source_type === 'epub') coverIcon = '📕'; else if (b.source_type === 'pdf') coverIcon = '📑'; else if (b.source_type === 'rfc') coverIcon = '🌐'; const badgeClass = isRfc ? 'badge-format badge-rfc' : 'badge-format'; card.innerHTML = `
${coverIcon}

${escapeHtml(b.title)}

${escapeHtml(b.author || (isRfc ? 'IETF' : 'Неизвестный автор'))}

${escapeHtml((b.source_type || 'text').toUpperCase())} ${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}
${progressLabels}
`; 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 = 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'); 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 (bookshelfEmpty) bookshelfEmpty.classList.add('hidden'); if (rfcEmptyState) rfcEmptyState.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'); if (bookshelfGrid) bookshelfGrid.innerHTML = ''; if (rfcGrid) rfcGrid.innerHTML = ''; const generalBooks = (books || []).filter(b => b.source_type !== 'rfc'); const rfcBooks = (books || []).filter(b => b.source_type === 'rfc'); if (bookshelfEmpty) { bookshelfEmpty.classList.toggle('hidden', generalBooks.length > 0); } if (bookshelfGrid) { generalBooks.forEach(b => { bookshelfGrid.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 === '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 = ` `; 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 = ` `; 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; } // 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(/(? 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: ... const htmlHeadingMatch = line.match(/^]*)>(.*?)<\/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
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '
' || trimmed === '
' || trimmed === '
') { 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 (

or

) 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) || /^]*>/i.test(curTrim) || (curTrim.startsWith('|') && curTrim.endsWith('|')) || curTrim.startsWith('>') || /^[-*+]\s+/.test(curTrim) || /^\d+\.\s+/.test(curTrim) || /^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) || /^<(?: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(/^]*>/i, '').replace(/<\/p>$/i, ''); processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, ''); // Convert Markdown inline patterns into HTML processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); processed = processed.replace(/`([^`]+)`/g, '$1'); processed = processed.replace(/\*\*([^*]+)\*\*/g, '$1'); processed = processed.replace(/__([^_]+)__/g, '$1'); processed = processed.replace(/(?$1'); processed = processed.replace(/(?$1'); 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)); }); } const READER_HONORIFICS = [ 'Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.', 'St.', 'vs.', 'etc.', 'e.g.', 'i.e.', 'Jr.', 'Sr.', 'Capt.', 'Col.', 'Gen.', 'Lt.', 'Sgt.', 'Rev.', 'Hon.' ]; export function extractContextSentence(fullText, word) { if (!fullText) return ''; let protectedText = fullText; for (const h of READER_HONORIFICS) { if (protectedText.toLowerCase().includes(h.toLowerCase())) { const safe = h.replace(/\./g, '§DOT§'); const reH = new RegExp(escapeRegex(h), 'gi'); protectedText = protectedText.replace(reH, safe); } } // Protect decimal numbers (e.g. 3.14, 1.5) protectedText = protectedText.replace(/(\d+)\.(\d+)/g, '$1§DOT§$2'); // Split sentences on terminal punctuation (.!?) followed by optional quotes/brackets and whitespace const marked = protectedText.replace(/([.!?]['"”’»)]*)\s+/g, '$1§SEP§'); const rawSentences = marked.split('§SEP§'); const cleanWord = (word || '').trim(); if (cleanWord) { const escaped = escapeRegex(cleanWord); const wordRe = new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i'); for (const s of rawSentences) { if (wordRe.test(s)) { return s.replace(/§DOT§/g, '.').trim(); } } } return 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) { 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, 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); } 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')?.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); 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 = `⏳ Загрузка и разбор спецификации RFC...`; rfcFetchStatus.classList.remove('hidden'); } try { const book = await api.books.fetchRfc(query); if (rfcFetchStatus) { rfcFetchStatus.className = 'rfc-fetch-status rfc-status-success'; rfcFetchStatus.innerHTML = `✅ ${escapeHtml(book.title)} успешно добавлен!`; } 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 = `❌ Ошибка: ${escapeHtml(err.message || 'Не удалось загрузить RFC')}`; 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); }); }); } }