feat(reader): full markdown rendering, styled code blocks and precise popup positioning
This commit is contained in:
1 parent
e7586ee1b4
commit
cbd65decd5
4 files changed
+414
-60
No files matched your search
@@ -7,26 +7,26 @@
|
|||||||
## 🚀 Очередь реализации (Пошаговый план)
|
## 🚀 Очередь реализации (Пошаговый план)
|
||||||
|
|
||||||
### 1. 📑 Полноценный рендеринг Markdown (.md) в читалке
|
### 1. 📑 Полноценный рендеринг Markdown (.md) в читалке
|
||||||
- [ ] **Блоки кода (Fenced Code Blocks ```` ```language ````)**:
|
- [x] **Блоки кода (Fenced Code Blocks ```` ```language ````)**:
|
||||||
- Выделение блоков кода в контейнер `<pre><code class="language-...">`.
|
- Выделение блоков кода в контейнер `<pre><code class="language-...">`.
|
||||||
- Моноширинный шрифт (`JetBrains Mono`, `Consolas`), сохранение отступов и табуляции (`white-space: pre`).
|
- Моноширинный шрифт (`JetBrains Mono`, `Consolas`), сохранение отступов и табуляции (`white-space: pre`).
|
||||||
- Тёмная подложка, горизонтальный скролл при переполнении, отключение красной строки (`text-indent: 0`).
|
- Тёмная подложка, горизонтальный скролл при переполнении, отключение красной строки (`text-indent: 0`).
|
||||||
- Кнопка «Копировать код» в шапке блока.
|
- Кнопка «Копировать код» в шапке блока.
|
||||||
- Отключение создания интерактивных словарных спанов `<span class="reader-word">` для кода, чтобы синтаксис не ломал форматирование.
|
- Отключение создания интерактивных словарных спанов `<span class="reader-word">` для кода, чтобы синтаксис не ломал форматирование.
|
||||||
- [ ] **Инлайн-код (`` `variable` ``)**:
|
- [x] **Инлайн-код (`` `variable` ``)**:
|
||||||
- Оборачивание в нативный тег `<code class="reader-inline-code">` с бейджик-стилем.
|
- Оборачивание в нативный тег `<code class="reader-inline-code">` с бейджик-стилем.
|
||||||
- [ ] **Списки и цитаты**:
|
- [x] **Списки и цитаты**:
|
||||||
- Преобразование маркированных (`- `, `* `) и нумерованных (`1. `) списков в `<ul><li>` / `<ol><li>`.
|
- Преобразование маркированных (`- `, `* `) и нумерованных (`1. `) списков в `<ul><li>` / `<ol><li>`.
|
||||||
- Преобразование цитат (`> `) в `<blockquote>` с акцентной левой границей.
|
- Преобразование цитат (`> `) в `<blockquote>` с акцентной левой границей.
|
||||||
- **Слова внутри списков и цитат остаются кликабельными** (интерактивный клик-перевод работает!).
|
- **Слова внутри списков и цитат остаются кликабельными** (интерактивный клик-перевод работает!).
|
||||||
- [ ] **Заголовки Markdown (`##`, `###`)**:
|
- [x] **Заголовки Markdown (`##`, `###`)**:
|
||||||
- Конвертация в `<h3>`, `<h4>` с удалением сырых решёток `#`.
|
- Конвертация в `<h3>`, `<h4>` с удалением сырых решёток `#`.
|
||||||
- [ ] **Точное позиционирование окна перевода у курсора/слова (Popup Positioning)**:
|
- [x] **Точное позиционирование окна перевода у курсора/слова (Popup Positioning)**:
|
||||||
- Исправить баг двойного смещения координат: у элемента `.reader-word-popup` в CSS задан `position: fixed`, а в JS к `rect.bottom` ошибочно прибавлялся `window.scrollY`, из-за чего окно улетало далеко вниз при любом скролле страницы.
|
- Исправлен баг двойного смещения координат: убран `window.scrollY` при `position: fixed`.
|
||||||
- Позиционировать окно точно под кликнутым словом (`rect.bottom + 6`) либо над ним (`rect.top - popupHeight - 6`), если снизу недостаточно места до края экрана.
|
- Окно позиционируется точно под кликнутым словом (`rect.bottom + 8`) либо над ним (`rect.top - popupHeight - 8`), если снизу недостаточно места.
|
||||||
- Центрировать окно относительно слова с ограничением по границам вьюпорта (отступы минимум 12px от краёв экрана).
|
- Окно центрируется относительно кликнутого слова с проверкой границ вьюпорта (отступы минимум 12px от краёв).
|
||||||
- [ ] **Стилизация в CSS ([`app/static/css/style.css`](file:///home/ai-projects/services/study.qpid.pro/app/static/css/style.css))**:
|
- [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-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 h1,
|
||||||
.reader-content-body h2,
|
.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);
|
font-family: var(--font-family);
|
||||||
margin: 28px 0 16px;
|
margin: 28px 0 16px;
|
||||||
color: var(--text-main);
|
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 {
|
.reader-content-body p {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-indent: 1.2em;
|
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 */
|
/* Interactive Words */
|
||||||
.reader-word {
|
.reader-word {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
<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 -->
|
<!-- PWA meta and icons -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
@@ -922,6 +922,6 @@
|
|||||||
<!-- Audio Element for native streaming -->
|
<!-- Audio Element for native streaming -->
|
||||||
<audio id="global-audio-player" preload="none"></audio>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+268
-47
@@ -1846,44 +1846,75 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
|
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Split content into paragraphs
|
// Parse content into structured blocks (code, headings, lists, quotes, paragraphs)
|
||||||
const paragraphs = chapter.content.split(/\n\s*\n/);
|
const blocks = parseContentBlocks(chapter.content);
|
||||||
paragraphs.forEach(paraText => {
|
|
||||||
paraText = paraText.trim();
|
|
||||||
if (!paraText) return;
|
|
||||||
|
|
||||||
const p = document.createElement('p');
|
blocks.forEach(block => {
|
||||||
// Tokenize words while preserving spaces & punctuation
|
if (block.type === 'code') {
|
||||||
const tokens = paraText.split(/([A-Za-z0-9'-]+)/);
|
const codeBlock = document.createElement('div');
|
||||||
tokens.forEach(token => {
|
codeBlock.className = 'reader-code-block';
|
||||||
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();
|
const header = document.createElement('div');
|
||||||
if (knownWordsMap.has(lower)) {
|
header.className = 'reader-code-header';
|
||||||
const card = knownWordsMap.get(lower);
|
|
||||||
span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
|
const langSpan = document.createElement('span');
|
||||||
}
|
langSpan.className = 'reader-code-lang';
|
||||||
if (isHighlightEnabled) {
|
langSpan.textContent = block.lang || 'code';
|
||||||
span.classList.add('highlight-enabled');
|
|
||||||
|
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) => {
|
header.appendChild(langSpan);
|
||||||
e.stopPropagation();
|
header.appendChild(copyBtn);
|
||||||
handleWordClick(token, paraText, span);
|
|
||||||
});
|
|
||||||
|
|
||||||
p.appendChild(span);
|
const pre = document.createElement('pre');
|
||||||
} else {
|
const codeEl = document.createElement('code');
|
||||||
p.appendChild(document.createTextNode(token));
|
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;
|
if (readerScrollArea) readerScrollArea.scrollTop = 0;
|
||||||
@@ -1896,6 +1927,176 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}).catch(err => console.log('Progress save error:', err));
|
}).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
|
// Extract cleanest surrounding sentence for context
|
||||||
function extractContextSentence(fullText, word) {
|
function extractContextSentence(fullText, word) {
|
||||||
if (!fullText) return '';
|
if (!fullText) return '';
|
||||||
@@ -1910,6 +2111,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
openWordPopup(word, sentence, targetElement);
|
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) {
|
async function openWordPopup(term, contextSentence, targetElement) {
|
||||||
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
|
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
|
||||||
if (!cleanTerm) return;
|
if (!cleanTerm) return;
|
||||||
@@ -1932,20 +2164,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Position popup near target element
|
// Position popup near target element
|
||||||
readerWordPopup.classList.remove('hidden');
|
readerWordPopup.classList.remove('hidden');
|
||||||
if (targetElement) {
|
positionWordPopup(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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/lookup?q=${encodeURIComponent(cleanTerm)}`);
|
const resp = await fetch(`/api/lookup?q=${encodeURIComponent(cleanTerm)}`);
|
||||||
@@ -1958,18 +2177,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
popupTranslation.textContent = trans || 'Перевод не найден';
|
popupTranslation.textContent = trans || 'Перевод не найден';
|
||||||
popupLoading.classList.add('hidden');
|
popupLoading.classList.add('hidden');
|
||||||
popupContent.classList.remove('hidden');
|
popupContent.classList.remove('hidden');
|
||||||
|
|
||||||
popupAudioBtn.classList.add('hidden');
|
popupAudioBtn.classList.add('hidden');
|
||||||
|
positionWordPopup(targetElement);
|
||||||
} else {
|
} else {
|
||||||
popupTranslation.textContent = 'Ошибка перевода';
|
popupTranslation.textContent = 'Ошибка перевода';
|
||||||
popupLoading.classList.add('hidden');
|
popupLoading.classList.add('hidden');
|
||||||
popupContent.classList.remove('hidden');
|
popupContent.classList.remove('hidden');
|
||||||
|
positionWordPopup(targetElement);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Popup lookup error:', e);
|
console.error('Popup lookup error:', e);
|
||||||
popupTranslation.textContent = 'Сетевая ошибка';
|
popupTranslation.textContent = 'Сетевая ошибка';
|
||||||
popupLoading.classList.add('hidden');
|
popupLoading.classList.add('hidden');
|
||||||
popupContent.classList.remove('hidden');
|
popupContent.classList.remove('hidden');
|
||||||
|
positionWordPopup(targetElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user