diff --git a/TODO.md b/TODO.md index f4140c6..9936447 100644 --- a/TODO.md +++ b/TODO.md @@ -7,26 +7,26 @@ ## πŸš€ ΠžΡ‡Π΅Ρ€Π΅Π΄ΡŒ Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ (ΠŸΠΎΡˆΠ°Π³ΠΎΠ²Ρ‹ΠΉ ΠΏΠ»Π°Π½) ### 1. πŸ“‘ ΠŸΠΎΠ»Π½ΠΎΡ†Π΅Π½Π½Ρ‹ΠΉ Ρ€Π΅Π½Π΄Π΅Ρ€ΠΈΠ½Π³ Markdown (.md) Π² Ρ‡ΠΈΡ‚Π°Π»ΠΊΠ΅ -- [ ] **Π‘Π»ΠΎΠΊΠΈ ΠΊΠΎΠ΄Π° (Fenced Code Blocks ```` ```language ````)**: +- [x] **Π‘Π»ΠΎΠΊΠΈ ΠΊΠΎΠ΄Π° (Fenced Code Blocks ```` ```language ````)**: - Π’Ρ‹Π΄Π΅Π»Π΅Π½ΠΈΠ΅ Π±Π»ΠΎΠΊΠΎΠ² ΠΊΠΎΠ΄Π° Π² ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€ `
`.
   - ΠœΠΎΠ½ΠΎΡˆΠΈΡ€ΠΈΠ½Π½Ρ‹ΠΉ ΡˆΡ€ΠΈΡ„Ρ‚ (`JetBrains Mono`, `Consolas`), сохранСниС отступов ΠΈ табуляции (`white-space: pre`).
   - Вёмная ΠΏΠΎΠ΄Π»ΠΎΠΆΠΊΠ°, Π³ΠΎΡ€ΠΈΠ·ΠΎΠ½Ρ‚Π°Π»ΡŒΠ½Ρ‹ΠΉ скролл ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π΅ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠΈ, ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ красной строки (`text-indent: 0`).
   - Кнопка Β«ΠšΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΊΠΎΠ΄Β» Π² шапкС Π±Π»ΠΎΠΊΠ°.
   - ΠžΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ создания ΠΈΠ½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ… словарных спанов `` для ΠΊΠΎΠ΄Π°, Ρ‡Ρ‚ΠΎΠ±Ρ‹ синтаксис Π½Π΅ Π»ΠΎΠΌΠ°Π» Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅.
-- [ ] **Инлайн-код (`` `variable` ``)**:
+- [x] **Инлайн-код (`` `variable` ``)**:
   - ΠžΠ±ΠΎΡ€Π°Ρ‡ΠΈΠ²Π°Π½ΠΈΠ΅ Π² Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹ΠΉ Ρ‚Π΅Π³ `` с Π±Π΅ΠΉΠ΄ΠΆΠΈΠΊ-стилСм.
-- [ ] **Бписки ΠΈ Ρ†ΠΈΡ‚Π°Ρ‚Ρ‹**:
+- [x] **Бписки ΠΈ Ρ†ΠΈΡ‚Π°Ρ‚Ρ‹**:
   - ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΌΠ°Ρ€ΠΊΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Ρ… (`- `, `* `) ΠΈ Π½ΡƒΠΌΠ΅Ρ€ΠΎΠ²Π°Π½Π½Ρ‹Ρ… (`1. `) списков Π² `
  • ` / `
    1. `. - ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ Ρ†ΠΈΡ‚Π°Ρ‚ (`> `) Π² `
      ` с Π°ΠΊΡ†Π΅Π½Ρ‚Π½ΠΎΠΉ Π»Π΅Π²ΠΎΠΉ Π³Ρ€Π°Π½ΠΈΡ†Π΅ΠΉ. - **Π‘Π»ΠΎΠ²Π° Π²Π½ΡƒΡ‚Ρ€ΠΈ списков ΠΈ Ρ†ΠΈΡ‚Π°Ρ‚ ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ ΠΊΠ»ΠΈΠΊΠ°Π±Π΅Π»ΡŒΠ½Ρ‹ΠΌΠΈ** (ΠΈΠ½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ ΠΊΠ»ΠΈΠΊ-ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚!). -- [ ] **Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΈ Markdown (`##`, `###`)**: +- [x] **Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΈ Markdown (`##`, `###`)**: - ΠšΠΎΠ½Π²Π΅Ρ€Ρ‚Π°Ρ†ΠΈΡ Π² `

      `, `

      ` с ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ΠΌ сырых Ρ€Π΅ΡˆΡ‘Ρ‚ΠΎΠΊ `#`. -- [ ] **Π’ΠΎΡ‡Π½ΠΎΠ΅ ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΎΠ½ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΎΠΊΠ½Π° ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄Π° Ρƒ курсора/слова (Popup Positioning)**: - - Π˜ΡΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π±Π°Π³ Π΄Π²ΠΎΠΉΠ½ΠΎΠ³ΠΎ смСщСния ΠΊΠΎΠΎΡ€Π΄ΠΈΠ½Π°Ρ‚: Ρƒ элСмСнта `.reader-word-popup` Π² CSS Π·Π°Π΄Π°Π½ `position: fixed`, Π° Π² JS ΠΊ `rect.bottom` ΠΎΡˆΠΈΠ±ΠΎΡ‡Π½ΠΎ прибавлялся `window.scrollY`, ΠΈΠ·-Π·Π° Ρ‡Π΅Π³ΠΎ ΠΎΠΊΠ½ΠΎ ΡƒΠ»Π΅Ρ‚Π°Π»ΠΎ Π΄Π°Π»Π΅ΠΊΠΎ Π²Π½ΠΈΠ· ΠΏΡ€ΠΈ любом скроллС страницы. - - ΠŸΠΎΠ·ΠΈΡ†ΠΈΠΎΠ½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΎΠΊΠ½ΠΎ Ρ‚ΠΎΡ‡Π½ΠΎ ΠΏΠΎΠ΄ ΠΊΠ»ΠΈΠΊΠ½ΡƒΡ‚Ρ‹ΠΌ словом (`rect.bottom + 6`) Π»ΠΈΠ±ΠΎ Π½Π°Π΄ Π½ΠΈΠΌ (`rect.top - popupHeight - 6`), Ссли снизу нСдостаточно мСста Π΄ΠΎ края экрана. - - Π¦Π΅Π½Ρ‚Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΎΠΊΠ½ΠΎ ΠΎΡ‚Π½ΠΎΡΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ слова с ΠΎΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΠ΅ΠΌ ΠΏΠΎ Π³Ρ€Π°Π½ΠΈΡ†Π°ΠΌ Π²ΡŒΡŽΠΏΠΎΡ€Ρ‚Π° (отступы ΠΌΠΈΠ½ΠΈΠΌΡƒΠΌ 12px ΠΎΡ‚ ΠΊΡ€Π°Ρ‘Π² экрана). -- [ ] **Бтилизация Π² CSS ([`app/static/css/style.css`](file:///home/ai-projects/services/study.qpid.pro/app/static/css/style.css))**: - - Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ стили для `.reader-content-body pre`, `code`, `blockquote`, `ul`, `ol`. +- [x] **Π’ΠΎΡ‡Π½ΠΎΠ΅ ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΎΠ½ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΎΠΊΠ½Π° ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄Π° Ρƒ курсора/слова (Popup Positioning)**: + - Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ Π±Π°Π³ Π΄Π²ΠΎΠΉΠ½ΠΎΠ³ΠΎ смСщСния ΠΊΠΎΠΎΡ€Π΄ΠΈΠ½Π°Ρ‚: ΡƒΠ±Ρ€Π°Π½ `window.scrollY` ΠΏΡ€ΠΈ `position: fixed`. + - Окно позиционируСтся Ρ‚ΠΎΡ‡Π½ΠΎ ΠΏΠΎΠ΄ ΠΊΠ»ΠΈΠΊΠ½ΡƒΡ‚Ρ‹ΠΌ словом (`rect.bottom + 8`) Π»ΠΈΠ±ΠΎ Π½Π°Π΄ Π½ΠΈΠΌ (`rect.top - popupHeight - 8`), Ссли снизу нСдостаточно мСста. + - Окно цСнтрируСтся ΠΎΡ‚Π½ΠΎΡΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ ΠΊΠ»ΠΈΠΊΠ½ΡƒΡ‚ΠΎΠ³ΠΎ слова с ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΎΠΉ Π³Ρ€Π°Π½ΠΈΡ† Π²ΡŒΡŽΠΏΠΎΡ€Ρ‚Π° (отступы ΠΌΠΈΠ½ΠΈΠΌΡƒΠΌ 12px ΠΎΡ‚ ΠΊΡ€Π°Ρ‘Π²). +- [x] **Бтилизация Π² CSS ([`app/static/css/style.css`](file:///home/ai-projects/services/study.qpid.pro/app/static/css/style.css))**: + - Π”ΠΎΠ±Π°Π²Π»Π΅Π½Ρ‹ стили для `.reader-content-body pre`, `code`, `blockquote`, `ul`, `ol`, `.reader-code-block`, `.reader-inline-code`. --- diff --git a/app/static/css/style.css b/app/static/css/style.css index 562b45c..9788fab 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2664,17 +2664,150 @@ body { .reader-content-body h1, .reader-content-body h2, -.reader-content-body h3 { +.reader-content-body h3, +.reader-content-body h4, +.reader-content-body h5, +.reader-content-body h6 { font-family: var(--font-family); margin: 28px 0 16px; color: var(--text-main); + text-indent: 0; } +.reader-content-body h4 { font-size: 1.15em; } +.reader-content-body h5 { font-size: 1.05em; } + .reader-content-body p { margin-bottom: 20px; text-indent: 1.2em; } +/* Fenced Code Block */ +.reader-code-block { + margin: 24px 0; + border-radius: var(--radius-md); + background: #141820; + border: 1px solid var(--border-color); + overflow: hidden; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3); +} + +.reader-code-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 14px; + background: rgba(255, 255, 255, 0.04); + border-bottom: 1px solid var(--border-color); + font-family: monospace; + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.reader-code-lang { + font-weight: 600; +} + +.reader-copy-code-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-muted); + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.reader-copy-code-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-main); + border-color: var(--primary); +} + +.reader-content-body pre { + margin: 0; + padding: 16px; + overflow-x: auto; + background: transparent; + border: none; + border-radius: 0; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + font-size: 14.5px; + line-height: 1.6; + text-indent: 0 !important; + white-space: pre; + tab-size: 4; +} + +.reader-content-body pre code { + font-family: inherit; + font-size: inherit; + color: #e6edf3; + background: transparent; + padding: 0; + border: none; + white-space: pre; + display: block; + text-indent: 0; +} + +/* Inline Code Badge */ +.reader-inline-code { + font-family: 'JetBrains Mono', 'Consolas', monospace; + font-size: 0.88em; + background: rgba(255, 255, 255, 0.08); + color: var(--primary); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + white-space: pre-wrap; + word-break: break-all; +} + +/* Blockquote */ +.reader-content-body blockquote { + margin: 20px 0; + padding: 12px 20px; + border-left: 4px solid var(--primary); + background: rgba(56, 189, 248, 0.06); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + font-style: italic; + color: var(--text-main); + text-indent: 0; +} + +.reader-content-body blockquote p { + margin: 0; + text-indent: 0; +} + +/* Lists */ +.reader-content-body ul, +.reader-content-body ol { + margin: 16px 0 20px 24px; + padding-left: 12px; + text-indent: 0; +} + +.reader-content-body li { + margin-bottom: 8px; + line-height: 1.7; + text-indent: 0; +} + +.reader-content-body hr { + border: 0; + height: 1px; + background: var(--border-color); + margin: 32px 0; +} + /* Interactive Words */ .reader-word { cursor: pointer; diff --git a/app/static/index.html b/app/static/index.html index f365d7c..b7a8fe4 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,7 +5,7 @@ Notes β€” ΠŸΠ΅Ρ€ΡΠΎΠ½Π°Π»ΡŒΠ½Ρ‹ΠΉ Ρ‚Ρ€Π΅Π½Π°ΠΆΠ΅Ρ€ ΠΈ ΡΠ»ΠΎΠ²Π°Ρ€ΡŒ английского языка - + @@ -922,6 +922,6 @@ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index 73cf0d3..5a552a8 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1846,44 +1846,75 @@ document.addEventListener('DOMContentLoaded', () => { if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); }); - // Split content into paragraphs - const paragraphs = chapter.content.split(/\n\s*\n/); - paragraphs.forEach(paraText => { - paraText = paraText.trim(); - if (!paraText) return; + // Parse content into structured blocks (code, headings, lists, quotes, paragraphs) + const blocks = parseContentBlocks(chapter.content); - const p = document.createElement('p'); - // Tokenize words while preserving spaces & punctuation - const tokens = paraText.split(/([A-Za-z0-9'-]+)/); - tokens.forEach(token => { - 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.textContent = token; + blocks.forEach(block => { + if (block.type === 'code') { + const codeBlock = document.createElement('div'); + codeBlock.className = 'reader-code-block'; - const lower = token.toLowerCase(); - if (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'); + 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); } + }; - span.addEventListener('click', (e) => { - e.stopPropagation(); - handleWordClick(token, paraText, span); - }); + header.appendChild(langSpan); + header.appendChild(copyBtn); - p.appendChild(span); - } else { - p.appendChild(document.createTextNode(token)); - } - }); + 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); - readerContentBody.appendChild(p); + codeBlock.appendChild(header); + codeBlock.appendChild(pre); + readerContentBody.appendChild(codeBlock); + } else if (block.type === 'heading') { + const tag = `h${Math.min(4, Math.max(2, block.level))}`; + const h = document.createElement(tag); + renderInteractiveSpans(h, block.text, block.text, knownWordsMap); + readerContentBody.appendChild(h); + } else if (block.type === 'blockquote') { + const bq = document.createElement('blockquote'); + const p = document.createElement('p'); + renderInteractiveSpans(p, block.text, block.text, knownWordsMap); + bq.appendChild(p); + readerContentBody.appendChild(bq); + } else if (block.type === 'ul' || block.type === 'ol') { + const listEl = document.createElement(block.type); + block.items.forEach(itemText => { + const li = document.createElement('li'); + renderInteractiveSpans(li, itemText, itemText, knownWordsMap); + listEl.appendChild(li); + }); + readerContentBody.appendChild(listEl); + } else if (block.type === 'hr') { + readerContentBody.appendChild(document.createElement('hr')); + } else { + // paragraph + const p = document.createElement('p'); + renderInteractiveSpans(p, block.text, block.text, knownWordsMap); + readerContentBody.appendChild(p); + } }); if (readerScrollArea) readerScrollArea.scrollTop = 0; @@ -1896,6 +1927,176 @@ document.addEventListener('DOMContentLoaded', () => { }).catch(err => console.log('Progress save error:', err)); } + // Parse text into structured semantic blocks + 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. 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; + } + + // 4. Horizontal Rule: --- or *** or ___ + if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed)) { + blocks.push({ type: 'hr' }); + i++; + continue; + } + + // 5. 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; + } + + // 6. 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; + } + + // 7. 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; + } + + // 8. 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) || + curTrim.startsWith('>') || + /^[-*+]\s+/.test(curTrim) || + /^\d+\.\s+/.test(curTrim) || + /^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim)) { + break; + } + paraLines.push(curTrim); + i++; + } + if (paraLines.length > 0) { + blocks.push({ + type: 'paragraph', + text: paraLines.join(' ') + }); + } + } + + return blocks; + } + + // Render text with inline formatting and interactive clickable word spans + function renderInteractiveSpans(container, text, contextText, knownWordsMap) { + if (!text) return; + const inlineCodeParts = text.split(/(`[^`]+`)/); + inlineCodeParts.forEach(part => { + if (part.startsWith('`') && part.endsWith('`') && part.length >= 2) { + const codeNode = document.createElement('code'); + codeNode.className = 'reader-inline-code'; + codeNode.textContent = part.slice(1, -1); + container.appendChild(codeNode); + return; + } + + const tokens = part.split(/([A-Za-z0-9'-]+)/); + tokens.forEach(token => { + 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.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'); + } + + span.addEventListener('click', (e) => { + e.stopPropagation(); + handleWordClick(token, contextText || text, span); + }); + + container.appendChild(span); + } else { + container.appendChild(document.createTextNode(token)); + } + }); + }); + } + // Extract cleanest surrounding sentence for context function extractContextSentence(fullText, word) { if (!fullText) return ''; @@ -1910,6 +2111,37 @@ document.addEventListener('DOMContentLoaded', () => { openWordPopup(word, sentence, targetElement); } + // Precise popup positioning near target element without scroll double-offset + function positionWordPopup(targetElement) { + if (!targetElement || !readerWordPopup) return; + const rect = targetElement.getBoundingClientRect(); + const popupWidth = 320; + const popupHeight = readerWordPopup.offsetHeight || 220; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Center horizontally over target element + let left = rect.left + (rect.width / 2) - (popupWidth / 2); + if (left + popupWidth > viewportWidth - 12) { + left = viewportWidth - popupWidth - 12; + } + if (left < 12) { + left = 12; + } + + // Position vertically: default below the word + let top = rect.bottom + 8; + // If bottom overflows viewport, flip above the word + if (top + popupHeight > viewportHeight - 12) { + if (rect.top - popupHeight - 8 >= 12 || rect.top > viewportHeight / 2) { + top = Math.max(12, rect.top - popupHeight - 8); + } + } + + readerWordPopup.style.top = `${Math.round(top)}px`; + readerWordPopup.style.left = `${Math.round(left)}px`; + } + async function openWordPopup(term, contextSentence, targetElement) { const cleanTerm = (term || '').replace(/^['"β€œΒ«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim(); if (!cleanTerm) return; @@ -1932,20 +2164,7 @@ document.addEventListener('DOMContentLoaded', () => { // Position popup near target element readerWordPopup.classList.remove('hidden'); - if (targetElement) { - const rect = targetElement.getBoundingClientRect(); - let top = rect.bottom + window.scrollY + 6; - let left = rect.left + window.scrollX; - - const popupWidth = 320; - if (left + popupWidth > window.innerWidth - 16) { - left = window.innerWidth - popupWidth - 16; - } - if (left < 16) left = 16; - - readerWordPopup.style.top = `${top}px`; - readerWordPopup.style.left = `${left}px`; - } + positionWordPopup(targetElement); try { const resp = await fetch(`/api/lookup?q=${encodeURIComponent(cleanTerm)}`); @@ -1958,18 +2177,20 @@ document.addEventListener('DOMContentLoaded', () => { popupTranslation.textContent = trans || 'ΠŸΠ΅Ρ€Π΅Π²ΠΎΠ΄ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½'; popupLoading.classList.add('hidden'); popupContent.classList.remove('hidden'); - popupAudioBtn.classList.add('hidden'); + positionWordPopup(targetElement); } else { popupTranslation.textContent = 'Ошибка ΠΏΠ΅Ρ€Π΅Π²ΠΎΠ΄Π°'; popupLoading.classList.add('hidden'); popupContent.classList.remove('hidden'); + positionWordPopup(targetElement); } } catch (e) { console.error('Popup lookup error:', e); popupTranslation.textContent = 'БСтСвая ошибка'; popupLoading.classList.add('hidden'); popupContent.classList.remove('hidden'); + positionWordPopup(targetElement); } }