]*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, '
');
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));
});
}
export function robustSentenceSplit(text) {
if (!text) return [];
let s = text;
// Step 1: Protect decimal numbers (e.g. 3.14, 1.5)
s = s.replace(/(\d+)\.(\d+)/g, '$1§DOT§$2');
// Step 2: Protect acronyms with dots like U.S., U.K., e.g., i.e., a.m., p.m., Ph.D.
s = s.replace(/\b((?:[A-Za-z]\.){2,})/g, (m) => m.replace(/\./g, '§DOT§'));
// Step 3: Protect single-letter initials like 'John F. Kennedy', 'J. K. Rowling'
s = s.replace(/\b([A-Za-z])\.(?=\s+[A-Za-z0-9"“'‘«—])/g, '$1§DOT§');
// Step 4: Protect common honorifics, titles, and abbreviations
const abbrPattern = /\b(Mr|Mrs|Ms|Miss|Dr|Prof|Rev|Hon|Capt|Col|Gen|Lt|Cmdr|Sgt|Cpl|Pvt|Maj|Adm|Gov|Sen|Rep|Pres|Sec|Treas|Insp|Det|Supt|Messrs|Mmes|St|Mt|Ft|etc|vs|v|Jr|Sr|approx|app|apt|dept|univ|assn|assoc|bldg|co|corp|inc|ltd|est|misc|al|ibid|op|cit|ed|eds|trans|vol|vols|no|nos|sec|secs|ch|chs|fig|figs|p|pp|ave|blvd|rd|st|ct|ln|dr|hwy|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\./gi;
s = s.replace(abbrPattern, '$1§DOT§');
// Step 5: Protect ellipsis (... or ....)
s = s.replace(/\.{2,}/g, (m) => '§ELLIPSIS§' + '§DOT§'.repeat(m.length));
// Step 6: Protect any period followed by lowercase letter (e.g. 'word. otherword')
s = s.replace(/\.([\'"”’»)]*\s+[a-zа-яё])/g, '§DOT§$1');
// Step 7: Split on sentence terminators [.!?] followed by whitespace and uppercase/number/quote/start of dialog
const marked = s.replace(/([.!?]['"”’»)]*)(\s+)(?=[A-Z0-9"“'‘«—]|$)/g, '$1§SEP§');
return marked.split('§SEP§')
.map(part => part.replace(/§DOT§/g, '.').replace(/§ELLIPSIS§/g, '').trim())
.filter(Boolean);
}
export function extractContextSentence(fullText, word) {
if (!fullText) return '';
const sentences = robustSentenceSplit(fullText);
const cleanWord = (word || '').trim();
if (cleanWord) {
const escaped = escapeRegex(cleanWord);
const wordRe = new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i');
for (const s of sentences) {
if (wordRe.test(s)) {
return s.replace(/\s+/g, ' ').trim();
}
}
}
return sentences.length > 0 ? sentences[0].replace(/\s+/g, ' ').trim() : fullText.slice(0, 160).trim();
}
// -----------------------------------------------------------------------
// Interactive Word Popup
// -----------------------------------------------------------------------
export function positionWordPopup(targetElement) {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!targetElement || !readerWordPopup) return;
if (window.innerWidth <= 768) {
readerWordPopup.style.top = '';
readerWordPopup.style.left = '';
readerWordPopup.style.right = '';
readerWordPopup.style.bottom = '';
readerWordPopup.style.width = '';
return;
}
const rect = targetElement.getBoundingClientRect();
const popupWidth = (readerWordPopup.offsetWidth && readerWordPopup.offsetWidth >= 280 && readerWordPopup.offsetWidth <= 380)
? readerWordPopup.offsetWidth
: 340;
const popupHeight = readerWordPopup.offsetHeight || 220;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = rect.left + (rect.width / 2) - (popupWidth / 2);
if (left + popupWidth > viewportWidth - 16) {
left = viewportWidth - popupWidth - 16;
}
if (left < 16) {
left = 16;
}
const minTop = isZenMode ? 16 : 65;
let top = rect.bottom + 8;
if (top + popupHeight > viewportHeight - 16) {
if (rect.top - popupHeight - 8 >= minTop || rect.top > viewportHeight / 2) {
top = Math.max(minTop, rect.top - popupHeight - 8);
}
}
readerWordPopup.style.top = `${Math.round(top)}px`;
readerWordPopup.style.left = `${Math.round(left)}px`;
readerWordPopup.style.right = 'auto';
readerWordPopup.style.bottom = 'auto';
readerWordPopup.style.width = `${popupWidth}px`;
}
export async function openWordPopup(term, contextSentence, targetElement, paragraphContext = '') {
const readerWordPopup = document.getElementById('reader-word-popup');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const popupTerm = document.getElementById('popup-term');
const popupPhonetic = document.getElementById('popup-phonetic');
const popupTranslation = document.getElementById('popup-translation');
const popupContext = document.getElementById('popup-context');
const popupLoading = document.getElementById('popup-loading');
const popupContent = document.getElementById('popup-content');
const popupInLibrary = document.getElementById('popup-in-library');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
const popupAudioBtn = document.getElementById('popup-audio-btn');
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
if (!cleanTerm || !readerWordPopup) return;
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
const lowerClean = cleanTerm.toLowerCase();
const allCards = getAllCards() || [];
const existingCard = allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean);
popupCurrentData = {
term: cleanTerm,
contextSentence: contextSentence,
paragraphContext: paragraphContext,
targetElement: targetElement,
existingCard: existingCard,
};
if (popupTerm) popupTerm.textContent = cleanTerm;
if (popupPhonetic) popupPhonetic.textContent = '';
if (popupTranslation) popupTranslation.textContent = '';
if (popupContext) popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
if (popupLoading) popupLoading.classList.remove('hidden');
if (popupContent) popupContent.classList.add('hidden');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar');
const popupPhrasalCard = document.getElementById('popup-phrasal-card');
const popupPhrasalTerm = document.getElementById('popup-phrasal-term');
const popupPhrasalRaw = document.getElementById('popup-phrasal-raw');
const popupPhrasalTranslation = document.getElementById('popup-phrasal-translation');
const popupPhrasalLibraryBadge = document.getElementById('popup-phrasal-library-badge');
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
popupCurrentData.phrasalVerb = null;
popupCurrentData.isPhrasalMode = false;
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = '';
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = '';
if (popupAiSourceBadge) popupAiSourceBadge.classList.add('hidden');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.disabled = false;
popupTranslateSentenceBtn.textContent = '🌐 Перевести предложение';
if (popupSentenceAiBar) popupSentenceAiBar.classList.toggle('hidden', !contextSentence);
}
if (popupInLibrary) {
popupInLibrary.classList.toggle('hidden', !existingCard);
}
if (existingCard) {
if (popupSaveBtn) popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
if (popupAddContextBtn) {
if (contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else {
popupAddContextBtn.classList.add('hidden');
}
}
} else {
if (popupSavedBadge) popupSavedBadge.classList.add('hidden');
if (popupSaveBtn) {
popupSaveBtn.classList.remove('hidden');
popupSaveBtn.disabled = false;
}
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
readerWordPopup.classList.remove('hidden');
positionWordPopup(targetElement);
try {
const data = await api.lookup.search(cleanTerm, contextSentence);
popupCurrentData.lookup = data;
// Phrasal verb detection
if (data.phrasal_verb) {
const pv = data.phrasal_verb;
popupCurrentData.phrasalVerb = pv;
if (popupPhrasalCard) {
popupPhrasalCard.classList.remove('hidden');
if (popupPhrasalTerm) popupPhrasalTerm.textContent = pv.term;
if (popupPhrasalRaw) popupPhrasalRaw.textContent = `(в тексте: «${pv.raw}»)`;
if (popupPhrasalTranslation) popupPhrasalTranslation.textContent = pv.translation;
if (popupPhrasalLibraryBadge) popupPhrasalLibraryBadge.classList.toggle('hidden', !pv.in_library);
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
}
} else {
if (popupPhrasalCard) popupPhrasalCard.classList.add('hidden');
}
const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : '');
if (popupPhonetic) popupPhonetic.textContent = data.phonetic || '';
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
if (popupLoading) popupLoading.classList.add('hidden');
if (popupContent) popupContent.classList.remove('hidden');
if (popupAudioBtn) {
popupAudioBtn.classList.remove('hidden');
popupAudioBtn.style.display = 'inline-flex';
}
popupCurrentData.audioUrl = data.audio_url || '';
const inLib = Boolean(data.in_library || existingCard);
const libCard = data.library_card || existingCard;
if (inLib) {
popupCurrentData.existingCard = libCard;
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
if (popupSaveBtn) popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
if (popupAddContextBtn) {
if (contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else {
popupAddContextBtn.classList.add('hidden');
}
}
} else {
if (popupInLibrary) popupInLibrary.classList.add('hidden');
if (popupSaveBtn) popupSaveBtn.classList.remove('hidden');
if (popupSavedBadge) popupSavedBadge.classList.add('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
positionWordPopup(targetElement);
} catch (e) {
console.error('Popup lookup error:', e);
if (popupTranslation) popupTranslation.textContent = 'Сетевая ошибка';
if (popupLoading) popupLoading.classList.add('hidden');
if (popupContent) popupContent.classList.remove('hidden');
positionWordPopup(targetElement);
}
}
export async function handleWordClick(word, paragraphText, targetElement) {
const sentence = extractContextSentence(paragraphText, word);
openWordPopup(word, sentence, targetElement, paragraphText);
}
function updatePopupPositionOnScroll() {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (window.innerWidth <= 768) return;
if (!popupCurrentData || !popupCurrentData.targetElement || !popupCurrentData.targetElement.isConnected) {
readerWordPopup.classList.add('hidden');
return;
}
const rect = popupCurrentData.targetElement.getBoundingClientRect();
const headerOffset = 65;
if (rect.bottom < headerOffset || rect.top > window.innerHeight) {
readerWordPopup.classList.add('hidden');
return;
}
positionWordPopup(popupCurrentData.targetElement);
}
function schedulePopupUpdate() {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (popupScrollRaf) return;
popupScrollRaf = requestAnimationFrame(() => {
popupScrollRaf = null;
updatePopupPositionOnScroll();
});
}
// -----------------------------------------------------------------------
// Scroll Position Tracking & Metrics
// -----------------------------------------------------------------------
export function getReaderScrollMetrics() {
const readerScrollArea = document.getElementById('reader-scroll-area');
if (!readerScrollArea) return { scrollTop: 0, maxScroll: 0, pct: 0, fraction: 0 };
const scrollTop = readerScrollArea.scrollTop;
const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight);
const fraction = maxScroll > 0 ? Math.min(1.0, Math.max(0, scrollTop / maxScroll)) : 0;
const pct = Math.min(100, Math.max(0, Math.round(fraction * 100)));
return { scrollTop, maxScroll, pct, fraction };
}
export function getActiveAnchorIndex() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea || !readerContentBody) return null;
const blocks = readerContentBody.querySelectorAll('[data-block-idx]');
if (!blocks || blocks.length === 0) return null;
const containerTop = readerScrollArea.getBoundingClientRect().top;
for (let el of blocks) {
const rect = el.getBoundingClientRect();
if (rect.bottom >= containerTop + 10) {
return parseInt(el.getAttribute('data-block-idx'), 10);
}
}
return 0;
}
export function getActiveVisibleChapter() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea || !readerContentBody) return currentChapterIndex;
const anchors = readerContentBody.querySelectorAll('[id^="chapter-anchor-"]');
if (!anchors || anchors.length === 0) return currentChapterIndex;
const containerTop = readerScrollArea.getBoundingClientRect().top;
let activeIdx = 0;
for (let el of anchors) {
const rect = el.getBoundingClientRect();
if (rect.top <= containerTop + 140) {
const ch = parseInt(el.getAttribute('data-chapter-idx'), 10);
if (!isNaN(ch)) activeIdx = ch;
} else {
break;
}
}
return activeIdx;
}
export function updateReaderProgressBar() {
const readerChapterProgressFill = document.getElementById('reader-chapter-progress-fill');
const readerScrollPctBadge = document.getElementById('reader-scroll-pct-badge');
const { pct, fraction } = getReaderScrollMetrics();
if (readerSettings.flowMode === 'continuous') {
if (readerChapterProgressFill) {
readerChapterProgressFill.style.width = `${pct}%`;
}
if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
const totalCh = currentBook && currentBook.chapters ? currentBook.chapters.length : 1;
readerScrollPctBadge.title = `Вся книга: ${pct}% • Глава ${currentChapterIndex + 1} из ${totalCh}`;
}
return;
}
if (readerChapterProgressFill) {
readerChapterProgressFill.style.width = `${pct}%`;
}
if (currentBook) {
const totalCh = Math.max(1, (currentBook.chapters ? currentBook.chapters.length : 1));
const overallFraction = (currentChapterIndex + fraction) / totalCh;
const totalPct = Math.min(100, Math.max(0, Math.round(overallFraction * 100)));
if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`;
}
} else if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
}
// Update Zen HUD elements if present
const zenHudTitle = document.getElementById('zen-hud-title');
const zenHudChapter = document.getElementById('zen-hud-chapter');
const zenHudProgress = document.getElementById('zen-hud-progress');
if (zenHudTitle && currentBook) {
zenHudTitle.textContent = currentBook.title || 'Книга';
zenHudTitle.title = currentBook.title || 'Книга';
}
if (zenHudChapter && currentBook && currentBook.chapters) {
const totalCh = currentBook.chapters.length;
const ch = currentBook.chapters[currentChapterIndex];
const chTitle = ch ? (ch.title || `Глава ${currentChapterIndex + 1}`) : `Глава ${currentChapterIndex + 1}`;
zenHudChapter.textContent = totalCh > 1 ? `Гл. ${currentChapterIndex + 1}/${totalCh}` : chTitle;
zenHudChapter.title = chTitle;
}
if (zenHudProgress) {
zenHudProgress.textContent = `${pct}%`;
}
}
export function handleReaderScroll() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
if (!currentBook || !readerScrollArea) return;
updateReaderProgressBar();
// 1. Detect active visible chapter in continuous or multi-chapter view
if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.size > 1) {
const activeCh = getActiveVisibleChapter();
if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) {
currentChapterIndex = activeCh;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const desiredHash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
}
}
// 2. Infinite scroll: auto-append next chapter in chapters mode with autoload
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) {
const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight);
if (distanceToBottom < 700) {
const loadedArr = Array.from(loadedChaptersSet);
const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex;
if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) {
isAppendingChapter = true;
appendNextChapter(highestCh + 1, false);
setTimeout(() => { isAppendingChapter = false; }, 200);
}
}
}
const { scrollTop, fraction } = getReaderScrollMetrics();
const anchorIdx = getActiveAnchorIndex();
// 3. Instant local persistence
try {
const key = `notes_book_${currentBook.id}_pos`;
localStorage.setItem(key, JSON.stringify({
chapter: currentChapterIndex,
scrollTop: scrollTop,
fraction: fraction,
anchorIdx: anchorIdx,
savedAt: Date.now()
}));
localStorage.setItem('notes_active_book_id', currentBook.id);
localStorage.setItem('notes_active_view', 'reader-view');
} catch (e) {}
// 4. Debounced server sync (every 800ms)
clearTimeout(readerScrollSaveTimer);
readerScrollSaveTimer = setTimeout(() => {
if (!currentBook) return;
api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: fraction })
.catch(err => console.log('Progress save error:', err));
}, 800);
}
// -----------------------------------------------------------------------
// Reader Appearance & Settings
// -----------------------------------------------------------------------
export function loadSavedReaderSettings() {
try {
const saved = localStorage.getItem('notes_reader_settings');
if (saved) {
readerSettings = Object.assign({}, DEFAULT_READER_SETTINGS, JSON.parse(saved));
}
} catch (e) {}
}
export function saveReaderSettings() {
try {
localStorage.setItem('notes_reader_settings', JSON.stringify(readerSettings));
} catch (e) {}
}
export function applyReaderSettings() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const settingFontSizeVal = document.getElementById('setting-font-size-val');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea) return;
const anchorIdx = getActiveAnchorIndex();
// 1. Font size
readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`);
if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`;
// 2. Line height
readerScrollArea.style.setProperty('--reader-line-height', readerSettings.lineHeight);
// 3. Theme
readerScrollArea.classList.remove('reader-theme-dark', 'reader-theme-sepia', 'reader-theme-light', 'reader-theme-oled');
readerScrollArea.classList.add(`reader-theme-${readerSettings.theme}`);
// 4. Font family
readerScrollArea.classList.remove('reader-font-sans', 'reader-font-serif', 'reader-font-mono');
readerScrollArea.classList.add(`reader-font-${readerSettings.fontFamily}`);
// 5. Margin width
readerScrollArea.classList.remove('reader-margins-narrow', 'reader-margins-normal', 'reader-margins-wide');
readerScrollArea.classList.add(`reader-margins-${readerSettings.marginWidth}`);
// 6. Text align
readerScrollArea.classList.remove('reader-align-left', 'reader-align-justify');
readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`);
updateReaderSettingsUI();
// Re-anchor scroll position after font/layout change
if (anchorIdx !== null && anchorIdx !== undefined && readerContentBody) {
requestAnimationFrame(() => {
const el = readerContentBody.querySelector(`[data-block-idx="${anchorIdx}"]`);
if (el) {
const containerTop = readerScrollArea.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
readerScrollArea.scrollTop += (elTop - containerTop - 10);
}
});
}
}
export function updateReaderSettingsUI() {
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const settingFontSizeVal = document.getElementById('setting-font-size-val');
const readerFontFamilyGroup = document.getElementById('reader-font-family-group');
const readerLineHeightGroup = document.getElementById('reader-line-height-group');
const readerMarginsGroup = document.getElementById('reader-margins-group');
const readerThemeGroup = document.getElementById('reader-theme-group');
const readerAlignGroup = document.getElementById('reader-align-group');
const readerModeGroup = document.getElementById('reader-mode-group');
const readerAutoloadGroup = document.getElementById('reader-autoload-group');
const settingAutoloadRow = document.getElementById('setting-autoload-row');
if (!readerSettingsPanel) return;
if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`;
if (readerFontFamilyGroup) {
readerFontFamilyGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.font === readerSettings.fontFamily);
});
}
if (readerLineHeightGroup) {
readerLineHeightGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', Math.abs(parseFloat(btn.dataset.lh) - parseFloat(readerSettings.lineHeight)) < 0.05);
});
}
if (readerMarginsGroup) {
readerMarginsGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.margin === readerSettings.marginWidth);
});
}
if (readerThemeGroup) {
readerThemeGroup.querySelectorAll('.theme-chip').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === readerSettings.theme);
});
}
if (readerAlignGroup) {
readerAlignGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === readerSettings.textAlign);
});
}
if (readerModeGroup) {
readerModeGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.flow === readerSettings.flowMode);
});
}
if (settingAutoloadRow) {
settingAutoloadRow.style.display = (readerSettings.flowMode === 'chapters') ? '' : 'none';
}
if (readerAutoloadGroup) {
readerAutoloadGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', (btn.dataset.autoload === 'true') === readerSettings.autoLoadNext);
});
}
}
export function positionSettingsPanel() {
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const readerSettingsBtn = document.getElementById('reader-settings-btn');
if (!readerSettingsPanel || !readerSettingsBtn) return;
if (window.innerWidth <= 768) {
readerSettingsPanel.style.top = '';
readerSettingsPanel.style.left = '';
readerSettingsPanel.style.right = '';
readerSettingsPanel.style.maxHeight = '';
return;
}
const rect = readerSettingsBtn.getBoundingClientRect();
const panelWidth = 330;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = rect.right - panelWidth;
if (left < 16) left = 16;
if (left + panelWidth > viewportWidth - 16) {
left = viewportWidth - panelWidth - 16;
}
const top = Math.round(rect.bottom + 8);
const maxH = Math.max(260, viewportHeight - top - 16);
readerSettingsPanel.style.top = `${top}px`;
readerSettingsPanel.style.left = `${Math.round(left)}px`;
readerSettingsPanel.style.right = 'auto';
readerSettingsPanel.style.maxHeight = `${maxH}px`;
}
// -----------------------------------------------------------------------
// Zen Focus Mode
// -----------------------------------------------------------------------
export function wakeZenHud() {
if (!isZenMode) return;
const hud = document.getElementById('reader-zen-hud');
if (!hud) return;
hud.classList.remove('hud-idle');
if (zenHudIdleTimer) {
clearTimeout(zenHudIdleTimer);
}
zenHudIdleTimer = setTimeout(() => {
if (isZenMode && hud) {
hud.classList.add('hud-idle');
}
}, 3000);
}
export function toggleZenMode(forceState = null) {
const newState = (forceState !== null) ? Boolean(forceState) : !isZenMode;
if (isZenMode === newState) return;
isZenMode = newState;
const hud = document.getElementById('reader-zen-hud');
const zenBtn = document.getElementById('reader-zen-btn');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
if (isZenMode) {
document.body.classList.add('zen-mode-active');
if (zenBtn) zenBtn.classList.add('active');
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
if (hud) {
hud.classList.remove('hidden');
hud.classList.remove('hud-idle');
wakeZenHud();
}
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
} else {
document.body.classList.remove('zen-mode-active');
if (zenBtn) zenBtn.classList.remove('active');
if (hud) {
hud.classList.add('hidden');
hud.classList.remove('hud-idle');
}
if (zenHudIdleTimer) {
clearTimeout(zenHudIdleTimer);
zenHudIdleTimer = null;
}
if (document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
}
}
// -----------------------------------------------------------------------
// Reader Module Initialization
// -----------------------------------------------------------------------
export function initReader() {
loadSavedReaderSettings();
applyReaderSettings();
const readerSettingsBtn = document.getElementById('reader-settings-btn');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const readerSettingsCloseBtn = document.getElementById('reader-settings-close-btn');
const readerWordPopup = document.getElementById('reader-word-popup');
const settingFontDecBtn = document.getElementById('setting-font-dec-btn');
const settingFontIncBtn = document.getElementById('setting-font-inc-btn');
const readerFontFamilyGroup = document.getElementById('reader-font-family-group');
const readerLineHeightGroup = document.getElementById('reader-line-height-group');
const readerMarginsGroup = document.getElementById('reader-margins-group');
const readerThemeGroup = document.getElementById('reader-theme-group');
const readerAlignGroup = document.getElementById('reader-align-group');
const readerModeGroup = document.getElementById('reader-mode-group');
const readerAutoloadGroup = document.getElementById('reader-autoload-group');
const readerContentBody = document.getElementById('reader-content-body');
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerBackBtn = document.getElementById('reader-back-btn');
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
const readerTrainBtn = document.getElementById('reader-train-btn');
const readerZenBtn = document.getElementById('reader-zen-btn');
const zenHudExitBtn = document.getElementById('zen-hud-exit-btn');
if (readerZenBtn) {
readerZenBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleZenMode();
});
}
if (zenHudExitBtn) {
zenHudExitBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleZenMode(false);
});
}
// Wake HUD on user activity during zen mode
['mousemove', 'touchstart', 'scroll'].forEach(evt => {
window.addEventListener(evt, () => {
if (isZenMode) wakeZenHud();
}, { passive: true });
});
// Keyboard shortcut for Zen Mode (F / f / а / А) and exit on Escape
document.addEventListener('keydown', (e) => {
const tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
if (tag === 'input' || tag === 'textarea' || (e.target && e.target.isContentEditable)) {
return;
}
const readerView = document.getElementById('reader-view');
const readerInterface = document.getElementById('reader-interface');
const isReaderActive = readerView && !readerView.classList.contains('hidden') &&
readerInterface && !readerInterface.classList.contains('hidden');
if (!isReaderActive) return;
if (e.key === 'Escape' && isZenMode) {
e.preventDefault();
toggleZenMode(false);
return;
}
if ((e.key === 'f' || e.key === 'F' || e.key === 'а' || e.key === 'А') && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
toggleZenMode();
}
});
// Native fullscreen sync
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement && isZenMode) {
toggleZenMode(false);
}
});
const popupCloseBtn = document.getElementById('popup-close-btn');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupInLibrary = document.getElementById('popup-in-library');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
const popupTranslation = document.getElementById('popup-translation');
const addTextBtn = document.getElementById('add-text-btn');
const addBookModal = document.getElementById('add-book-modal');
const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn');
const cancelAddBookBtn = document.getElementById('cancel-add-book-btn');
const addBookForm = document.getElementById('add-book-form');
const bookTitleInput = document.getElementById('book-title-input');
const bookAuthorInput = document.getElementById('book-author-input');
const bookContentInput = document.getElementById('book-content-input');
const bookFileInput = document.getElementById('book-file-input');
// Settings panel triggers
if (readerSettingsBtn) {
readerSettingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (readerWordPopup) readerWordPopup.classList.add('hidden');
if (readerSettingsPanel) {
const isHidden = readerSettingsPanel.classList.contains('hidden');
readerSettingsPanel.classList.toggle('hidden', !isHidden);
if (isHidden) {
updateReaderSettingsUI();
positionSettingsPanel();
}
}
});
}
if (readerSettingsCloseBtn) {
readerSettingsCloseBtn.addEventListener('click', () => {
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
});
}
if (settingFontDecBtn) {
settingFontDecBtn.addEventListener('click', () => {
if (readerSettings.fontSize > 13) {
readerSettings.fontSize -= 1;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (settingFontIncBtn) {
settingFontIncBtn.addEventListener('click', () => {
if (readerSettings.fontSize < 34) {
readerSettings.fontSize += 1;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerFontFamilyGroup) {
readerFontFamilyGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.font) {
readerSettings.fontFamily = btn.dataset.font;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerLineHeightGroup) {
readerLineHeightGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.lh) {
readerSettings.lineHeight = parseFloat(btn.dataset.lh);
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerMarginsGroup) {
readerMarginsGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.margin) {
readerSettings.marginWidth = btn.dataset.margin;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerThemeGroup) {
readerThemeGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.theme-chip');
if (btn && btn.dataset.theme) {
readerSettings.theme = btn.dataset.theme;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerAlignGroup) {
readerAlignGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.align) {
readerSettings.textAlign = btn.dataset.align;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerModeGroup) {
readerModeGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.flow) {
if (readerSettings.flowMode !== btn.dataset.flow) {
readerSettings.flowMode = btn.dataset.flow;
saveReaderSettings();
updateReaderSettingsUI();
if (currentBook) {
if (readerSettings.flowMode === 'continuous') {
renderContinuousBook(currentChapterIndex);
} else {
renderChapter(currentChapterIndex);
}
}
}
}
});
}
if (readerAutoloadGroup) {
readerAutoloadGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.autoload !== undefined) {
readerSettings.autoLoadNext = (btn.dataset.autoload === 'true');
saveReaderSettings();
updateReaderSettingsUI();
const footerHint = readerContentBody ? readerContentBody.querySelector('.chapter-footer-hint') : null;
if (footerHint) {
footerHint.textContent = (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)
? '⚡ Автоподгрузка включена: следующая глава подгрузится автоматически при скролле вниз'
: '💡 Нажмите кнопку выше для перехода к следующей главе';
}
}
});
}
document.addEventListener('click', (e) => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) {
if (!readerSettingsPanel.contains(e.target) && e.target !== readerSettingsBtn && !readerSettingsBtn.contains(e.target)) {
readerSettingsPanel.classList.add('hidden');
}
}
});
window.addEventListener('resize', () => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) {
positionSettingsPanel();
}
}, { passive: true });
window.addEventListener('scroll', () => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden') && window.innerWidth > 768) {
positionSettingsPanel();
}
}, { passive: true });
// Word clicking & highlighting
if (readerContentBody) {
readerContentBody.addEventListener('click', (e) => {
const wordSpan = e.target.closest('.reader-word');
if (wordSpan) {
e.stopPropagation();
const word = wordSpan.dataset.word;
const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote, figcaption')?.textContent || word;
handleWordClick(word, context, wordSpan);
}
});
// Phrase/chunk selection in reader
readerContentBody.addEventListener('mouseup', () => {
const sel = window.getSelection();
const text = sel.toString().trim();
if (text && text.split(/\s+/).length >= 2 && text.length <= 80) {
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
openWordPopup(text, text, {
getBoundingClientRect: () => rect,
});
}
});
}
if (readerToggleHighlightBtn) {
readerToggleHighlightBtn.addEventListener('click', () => {
isHighlightEnabled = !isHighlightEnabled;
readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled);
if (readerContentBody) {
readerContentBody.querySelectorAll('.reader-word').forEach(w => {
w.classList.toggle('highlight-enabled', isHighlightEnabled);
});
}
});
}
if (readerTrainBtn) {
readerTrainBtn.addEventListener('click', () => {
if (currentBook) {
const bookTag = slugify(currentBook.title);
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
}
});
}
// Popup actions
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', () => {
if (readerWordPopup) readerWordPopup.classList.add('hidden');
});
}
document.addEventListener('click', (e) => {
if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) {
readerWordPopup.classList.add('hidden');
}
});
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
if (popupAudioBtn) {
popupAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.term) return;
const term = popupCurrentData.term;
const url = popupCurrentData.audioUrl || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
playAudio(url);
});
}
if (popupAudioSentenceBtn) {
popupAudioSentenceBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const sentence = popupCurrentData.contextSentence;
const url = `/api/tts?text=${encodeURIComponent(sentence)}&lang=en`;
playAudio(url);
});
}
if (popupAudioRuBtn) {
popupAudioRuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
const url = `/api/tts?text=${encodeURIComponent(ruText)}&lang=ru`;
playAudio(url);
});
}
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const ctxSentence = popupCurrentData.contextSentence;
const term = popupCurrentData.term;
popupTranslateSentenceBtn.disabled = true;
if (popupSentenceLoading) popupSentenceLoading.classList.remove('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
try {
const res = await api.lookup.translateSentence(ctxSentence, term, popupCurrentData.paragraphContext || '');
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) {
popupSentenceRu.textContent = res.sentence_ru || 'Перевод не получен';
}
if (res.word_meaning && res.word_meaning.trim()) {
popupCurrentData.contextMeaning = res.word_meaning.trim();
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.remove('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = res.word_meaning.trim();
} else {
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
}
if (popupAiSourceBadge) {
popupAiSourceBadge.classList.remove('hidden');
popupAiSourceBadge.textContent = res.cached ? '⚡ Кеш (0.01ms)' : '⚡ Local AI (Qwen)';
popupAiSourceBadge.title = res.source || '';
}
positionWordPopup(popupCurrentData.targetElement);
} catch (err) {
console.error('Sentence translation error:', err);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = 'Ошибка связи с локальным ИИ';
} finally {
popupTranslateSentenceBtn.disabled = false;
}
});
}
if (popupUseContextMeaningBtn) {
popupUseContextMeaningBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextMeaning) return;
const meaning = popupCurrentData.contextMeaning;
if (popupTranslation) popupTranslation.textContent = meaning;
if (popupCurrentData.lookup) {
popupCurrentData.lookup.translation = meaning;
}
popupUseContextMeaningBtn.textContent = '✓ Вставлено!';
setTimeout(() => {
if (popupUseContextMeaningBtn) popupUseContextMeaningBtn.textContent = 'Вставить в карточку';
}, 1800);
});
}
const popupSwitchPhrasalBtn = document.getElementById('popup-switch-phrasal-btn');
if (popupSwitchPhrasalBtn) {
popupSwitchPhrasalBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.phrasalVerb) return;
const pv = popupCurrentData.phrasalVerb;
if (!popupCurrentData.isPhrasalMode) {
// Switch to phrasal verb mode
popupCurrentData.isPhrasalMode = true;
if (popupTerm) popupTerm.textContent = pv.term;
if (popupTranslation) popupTranslation.textContent = pv.translation;
if (popupPhonetic) popupPhonetic.textContent = '';
const inLib = Boolean(pv.in_library);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = `💾 Сохранить «${pv.term}» в словарь`;
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Фразовый глагол уже в библиотеке';
}
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
popupSwitchPhrasalBtn.textContent = `↩️ Вернуться к «${popupCurrentData.term}»`;
} else {
// Revert back to single word
popupCurrentData.isPhrasalMode = false;
const origTrans = popupCurrentData.lookup ? (popupCurrentData.lookup.translation || popupCurrentData.lookup.translation_ru || '') : '';
if (popupTerm) popupTerm.textContent = popupCurrentData.term;
if (popupTranslation) popupTranslation.textContent = origTrans;
if (popupPhonetic) popupPhonetic.textContent = popupCurrentData.lookup?.phonetic || '';
const inLib = Boolean(popupCurrentData.lookup?.in_library || popupCurrentData.existingCard);
if (popupInLibrary) popupInLibrary.classList.toggle('hidden', !inLib);
if (popupSaveBtn) {
popupSaveBtn.classList.toggle('hidden', inLib);
popupSaveBtn.textContent = '💾 Добавить в словарь';
}
if (popupSavedBadge) {
popupSavedBadge.classList.toggle('hidden', !inLib);
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
popupSwitchPhrasalBtn.textContent = `🔗 Переключить на «${pv.term}»`;
}
positionWordPopup(popupCurrentData.targetElement);
});
}
if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const isPv = Boolean(d.isPhrasalMode && d.phrasalVerb);
const termToSave = isPv ? d.phrasalVerb.term : d.term;
const transToSave = isPv
? d.phrasalVerb.translation
: (lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''));
const collectionToSave = isPv ? 'Фразовые глаголы & Идиомы' : 'Книги & Сериалы';
const tagsToSave = isPv ? `${bookTag}, phrasal-verbs, reader` : `${bookTag}, reader`;
const payload = {
term: termToSave,
translation: transToSave,
context_sentence: d.contextSentence || '',
phonetic: isPv ? '' : (lookup.phonetic || ''),
audio_url: isPv ? '' : (lookup.audio_url || ''),
part_of_speech: isPv ? 'verb phrase' : (lookup.part_of_speech || ''),
definition_en: lookup.definition_en || '',
collection: collectionToSave,
tags: tagsToSave,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
try {
const resp = await api.cards.create(payload);
if (resp.ok) {
const resData = await resp.json();
popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = resData.already_exists ? '⭐ Уже есть в библиотеке' : '✅ Сохранено в словарь!';
}
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
if (d.targetElement) {
d.targetElement.classList.add('status-learning');
if (isHighlightEnabled) d.targetElement.classList.add('highlight-enabled');
}
await fetchCards();
} else {
alert('Не удалось сохранить слово');
}
} catch (e) {
console.error('Save card from reader error:', e);
}
});
}
if (popupAddContextBtn) {
popupAddContextBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const payload = {
term: d.term,
translation: lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''),
context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '',
audio_url: lookup.audio_url || '',
part_of_speech: lookup.part_of_speech || '',
definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы',
tags: `${bookTag}, reader`,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
popupAddContextBtn.disabled = true;
popupAddContextBtn.textContent = 'Сохраняем...';
try {
const resp = await api.cards.create(payload);
if (resp.ok) {
popupAddContextBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!';
popupSavedBadge.classList.remove('hidden');
}
await fetchCards();
} else {
alert('Не удалось обновить контекст');
}
} catch (e) {
console.error('Add context error:', e);
} finally {
popupAddContextBtn.disabled = false;
popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке';
}
});
}
// Chapter Navigation & Reader Toolbar
if (readerChapterSelect) {
readerChapterSelect.addEventListener('change', () => {
const ch = parseInt(readerChapterSelect.value, 10);
if (!currentBook) return;
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = ch;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${ch}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${ch}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(ch);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${ch}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`;
});
}
if (readerPrevChBtn) {
readerPrevChBtn.addEventListener('click', () => {
if (currentBook && currentChapterIndex > 0) {
const prev = currentChapterIndex - 1;
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = prev;
if (readerChapterSelect) readerChapterSelect.value = prev;
updateChapterSelectIcons();
readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${prev}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${prev}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(prev);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${prev}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
window.location.hash = `#reader/book/${currentBook.id}/ch/${prev}`;
}
});
}
if (readerNextChBtn) {
readerNextChBtn.addEventListener('click', () => {
if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) {
const next = currentChapterIndex + 1;
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = next;
if (readerChapterSelect) readerChapterSelect.value = next;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${next}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${next}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(next);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${next}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
return;
}
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) {
appendNextChapter(next, true);
} else {
window.location.hash = `#reader/book/${currentBook.id}/ch/${next}`;
}
}
});
}
if (readerBackBtn) {
readerBackBtn.addEventListener('click', () => {
if (isZenMode) toggleZenMode(false);
window.location.hash = '#reader';
});
}
if (readerScrollArea) {
readerScrollArea.addEventListener('scroll', handleReaderScroll, { passive: true });
}
window.addEventListener('beforeunload', () => {
if (currentBook && readerScrollArea) {
const { scrollTop, fraction } = getReaderScrollMetrics();
const anchorIdx = getActiveAnchorIndex();
try {
localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({
chapter: currentChapterIndex,
scrollTop: scrollTop,
fraction: fraction,
anchorIdx: anchorIdx,
savedAt: Date.now()
}));
localStorage.setItem('notes_active_book_id', currentBook.id);
} catch (e) {}
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify({ current_chapter: currentChapterIndex, current_position: fraction })], { type: 'application/json' });
navigator.sendBeacon(`/api/books/${currentBook.id}/progress`, blob);
}
}
});
// Bookshelf Add/Upload Actions
if (addTextBtn && addBookModal && addBookForm) {
addTextBtn.addEventListener('click', () => {
addBookForm.reset();
addBookModal.classList.remove('hidden');
});
}
if (closeAddBookModalBtn && addBookModal) {
closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
}
if (cancelAddBookBtn && addBookModal) {
cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
}
if (addBookForm) {
addBookForm.addEventListener('submit', async (e) => {
e.preventDefault();
const title = bookTitleInput ? bookTitleInput.value.trim() : '';
const author = bookAuthorInput ? bookAuthorInput.value.trim() : '';
const content = bookContentInput ? bookContentInput.value.trim() : '';
if (!title || !content) return;
try {
const resp = await api.books.createManual({
title: title,
author: author,
content: content,
source_type: 'text',
});
if (resp.ok) {
const book = await resp.json();
if (addBookModal) addBookModal.classList.add('hidden');
window.location.hash = `#reader/book/${book.id}`;
} else {
alert('Ошибка при сохранении книги');
}
} catch (err) {
console.error('Book create error:', err);
}
});
}
if (bookFileInput) {
bookFileInput.addEventListener('change', async () => {
const file = bookFileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await api.books.uploadFile(formData);
if (resp.ok) {
const book = await resp.json();
bookFileInput.value = '';
window.location.hash = `#reader/book/${book.id}`;
} else {
let errMsg = 'Не удалось обработать файл';
try {
const errData = await resp.json();
errMsg = errData.detail || errMsg;
} catch (_) {
if (resp.status === 413) {
errMsg = 'Файл слишком большой (превышен лимит размера в Nginx)';
} else {
errMsg = `Код ответа сервера: HTTP ${resp.status}`;
}
}
alert(`Ошибка загрузки: ${errMsg}`);
}
} catch (e) {
console.error('File upload error:', e);
alert('Ошибка сети или соединение прервано при загрузке файла');
}
});
}
// RFC Fetching & Presets
const rfcFetchForm = document.getElementById('rfc-fetch-form');
const rfcInput = document.getElementById('rfc-input');
const rfcFetchBtn = document.getElementById('rfc-fetch-btn');
const rfcFetchStatus = document.getElementById('rfc-fetch-status');
const rfcPresetChips = document.querySelectorAll('.rfc-preset-chip');
async function handleRfcFetch(query) {
if (!query) return;
query = query.trim();
if (!query) return;
if (rfcFetchBtn) rfcFetchBtn.disabled = true;
if (rfcInput) rfcInput.disabled = true;
if (rfcFetchStatus) {
rfcFetchStatus.className = 'rfc-fetch-status rfc-status-loading';
rfcFetchStatus.innerHTML = `⏳ Загрузка и разбор спецификации 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);
});
});
}
// -----------------------------------------------------------------------
// 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 = `⏳ ${isCustom ? 'Загрузка статьи по запросу...' : 'Подбираем и загружаем статью из Википедии...'}`;
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 = `✅ Статья «${escapeHtml(book.title)}» сохранена (~${estMin} мин, ${book.total_words ? book.total_words.toLocaleString() : ''} слов)!`;
}
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 = `❌ Ошибка: ${escapeHtml(err.message || 'Не удалось загрузить статью')}`;
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
});
});
}
}