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
+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