feat(reader): full markdown rendering, styled code blocks and precise popup positioning

This commit is contained in:
Antigravity committed 2026-10-05 12:52:59 +03:00
1 parent e7586ee1b4
commit cbd65decd5
4 files changed
+414 -60

No files matched your search

+10 -10
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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);
} }
} }