feat(reader): full markdown rendering, styled code blocks and precise popup positioning
This commit is contained in:
@@ -7,26 +7,26 @@
|
||||
## 🚀 Очередь реализации (Пошаговый план)
|
||||
|
||||
### 1. 📑 Полноценный рендеринг Markdown (.md) в читалке
|
||||
- [ ] **Блоки кода (Fenced Code Blocks ```` ```language ````)**:
|
||||
- [x] **Блоки кода (Fenced Code Blocks ```` ```language ````)**:
|
||||
- Выделение блоков кода в контейнер `<pre><code class="language-...">`.
|
||||
- Моноширинный шрифт (`JetBrains Mono`, `Consolas`), сохранение отступов и табуляции (`white-space: pre`).
|
||||
- Тёмная подложка, горизонтальный скролл при переполнении, отключение красной строки (`text-indent: 0`).
|
||||
- Кнопка «Копировать код» в шапке блока.
|
||||
- Отключение создания интерактивных словарных спанов `<span class="reader-word">` для кода, чтобы синтаксис не ломал форматирование.
|
||||
- [ ] **Инлайн-код (`` `variable` ``)**:
|
||||
- [x] **Инлайн-код (`` `variable` ``)**:
|
||||
- Оборачивание в нативный тег `<code class="reader-inline-code">` с бейджик-стилем.
|
||||
- [ ] **Списки и цитаты**:
|
||||
- [x] **Списки и цитаты**:
|
||||
- Преобразование маркированных (`- `, `* `) и нумерованных (`1. `) списков в `<ul><li>` / `<ol><li>`.
|
||||
- Преобразование цитат (`> `) в `<blockquote>` с акцентной левой границей.
|
||||
- **Слова внутри списков и цитат остаются кликабельными** (интерактивный клик-перевод работает!).
|
||||
- [ ] **Заголовки Markdown (`##`, `###`)**:
|
||||
- [x] **Заголовки Markdown (`##`, `###`)**:
|
||||
- Конвертация в `<h3>`, `<h4>` с удалением сырых решёток `#`.
|
||||
- [ ] **Точное позиционирование окна перевода у курсора/слова (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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+134
-1
@@ -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;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=2.2">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=2.3">
|
||||
|
||||
<!-- PWA meta and icons -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -922,6 +922,6 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script src="/static/js/app.js?v=2.2"></script>
|
||||
<script src="/static/js/app.js?v=2.3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+268
-47
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user