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

+268 -47
View File
@@ -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);
}
}