feat(reader): full Markdown and HTML rendering for GitHub READMEs, tables, images and links
This commit is contained in:
1 parent
c5e0e2dcfa
commit
ca70580e62
5 files changed
+432
-54
No files matched your search
@@ -21,12 +21,21 @@
|
|||||||
- **Слова внутри списков и цитат остаются кликабельными** (интерактивный клик-перевод работает!).
|
- **Слова внутри списков и цитат остаются кликабельными** (интерактивный клик-перевод работает!).
|
||||||
- [x] **Заголовки Markdown (`##`, `###`)**:
|
- [x] **Заголовки Markdown (`##`, `###`)**:
|
||||||
- Конвертация в `<h3>`, `<h4>` с удалением сырых решёток `#`.
|
- Конвертация в `<h3>`, `<h4>` с удалением сырых решёток `#`.
|
||||||
|
- [x] **Таблицы Markdown (`| col | col |`)**:
|
||||||
|
- Адаптивные таблицы с рамками, стилизацией заголовков `<thead>`, зебра-подсветкой строк и горизонтальным скроллом на мобильных.
|
||||||
|
- Каждая ячейка поддерживает интерактивные слова для изучения и инлайн-код.
|
||||||
|
- [x] **HTML-блоки и теги в Markdown (`<p align="center">`, `<h1>`, `<em>`, `<strong>`, `<sub>`, `<br>`)**:
|
||||||
|
- Нативный парсинг через встроенный `DOMParser` без внешних библиотек.
|
||||||
|
- Устранена утечка сырых тегов (`<picture>`, `<source>`, `<p align=...>`) в словарные слова.
|
||||||
|
- [x] **Ссылки и изображения (`[text](url)`, ``, `<img>`)**:
|
||||||
|
- Поддержка бейджей shields.io, тренд-графиков и внешних картинок.
|
||||||
|
- Автоматическое скрытие битых/отсутствующих локальных путей (`assets/...`) через обработчик `onerror`.
|
||||||
- [x] **Точное позиционирование окна перевода у курсора/слова (Popup Positioning)**:
|
- [x] **Точное позиционирование окна перевода у курсора/слова (Popup Positioning)**:
|
||||||
- Исправлен баг двойного смещения координат: убран `window.scrollY` при `position: fixed`.
|
- Исправлен баг двойного смещения координат: убран `window.scrollY` при `position: fixed`.
|
||||||
- Окно позиционируется точно под кликнутым словом (`rect.bottom + 8`) либо над ним (`rect.top - popupHeight - 8`), если снизу недостаточно места.
|
- Окно перенесено в корень `<body>` для исключения искажений контекста позиционирования.
|
||||||
- Окно центрируется относительно кликнутого слова с проверкой границ вьюпорта (отступы минимум 12px от краёв).
|
- Плавное отслеживание скролла через `requestAnimationFrame` с автозакрытием при уходе слова за границы экрана или под липкую шапку.
|
||||||
- [x] **Стилизация в 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-code-block`, `.reader-inline-code`.
|
- Добавлены стили для `.reader-content-body pre`, `code`, `blockquote`, `ul`, `ol`, `.reader-table`, `.reader-heading`, `.reader-link`, `.reader-img`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -294,8 +294,8 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple
|
|||||||
lines = content.split("\n")
|
lines = content.split("\n")
|
||||||
first_line = lines[0].strip()
|
first_line = lines[0].strip()
|
||||||
|
|
||||||
# Extract title from first line if default
|
# Extract title from first line or # Heading / <h1...> if default or generic README
|
||||||
if title == "Untitled":
|
if title == "Untitled" or title.lower().startswith("readme"):
|
||||||
if first_line.startswith("# "):
|
if first_line.startswith("# "):
|
||||||
title = first_line.lstrip("# ").strip()
|
title = first_line.lstrip("# ").strip()
|
||||||
if len(lines) > 1:
|
if len(lines) > 1:
|
||||||
@@ -304,6 +304,12 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple
|
|||||||
title = first_line.split(":", 1)[1].strip()
|
title = first_line.split(":", 1)[1].strip()
|
||||||
if len(lines) > 1:
|
if len(lines) > 1:
|
||||||
content = "\n".join(lines[1:]).strip()
|
content = "\n".join(lines[1:]).strip()
|
||||||
|
else:
|
||||||
|
h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", content[:1500], re.IGNORECASE)
|
||||||
|
if h1_match:
|
||||||
|
detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip()
|
||||||
|
if detected:
|
||||||
|
title = detected
|
||||||
|
|
||||||
# Extract author from next line if missing
|
# Extract author from next line if missing
|
||||||
if not author:
|
if not author:
|
||||||
|
|||||||
@@ -2808,6 +2808,140 @@ body {
|
|||||||
margin: 32px 0;
|
margin: 32px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.reader-content-body h1,
|
||||||
|
.reader-content-body h2,
|
||||||
|
.reader-content-body h3,
|
||||||
|
.reader-content-body h4,
|
||||||
|
.reader-content-body h5,
|
||||||
|
.reader-content-body h6 {
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 32px 0 16px;
|
||||||
|
text-indent: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body h2 {
|
||||||
|
font-size: 23px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body h3 {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body h4 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Tables */
|
||||||
|
.reader-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 24px 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-indent: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-table th,
|
||||||
|
.reader-table td {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
vertical-align: top;
|
||||||
|
text-indent: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-table th:last-child,
|
||||||
|
.reader-table td:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-table th {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-table tbody tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links & Images */
|
||||||
|
.reader-link {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-link:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: 8px 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body p img {
|
||||||
|
margin: 4px 6px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body p[style*="text-align: center"],
|
||||||
|
.reader-content-body p.text-center {
|
||||||
|
text-indent: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body strong,
|
||||||
|
.reader-content-body b {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body em,
|
||||||
|
.reader-content-body i {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-body sub,
|
||||||
|
.reader-content-body sup {
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
.reader-content-body sub { bottom: -0.25em; }
|
||||||
|
.reader-content-body sup { top: -0.5em; }
|
||||||
|
|
||||||
/* 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.5">
|
<link rel="stylesheet" href="/static/css/style.css?v=2.6">
|
||||||
|
|
||||||
<!-- 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.5"></script>
|
<script src="/static/js/app.js?v=2.6"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+276
-47
@@ -1888,16 +1888,54 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
codeBlock.appendChild(header);
|
codeBlock.appendChild(header);
|
||||||
codeBlock.appendChild(pre);
|
codeBlock.appendChild(pre);
|
||||||
readerContentBody.appendChild(codeBlock);
|
readerContentBody.appendChild(codeBlock);
|
||||||
|
} else if (block.type === 'table') {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'reader-table-wrap';
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.className = 'reader-table';
|
||||||
|
|
||||||
|
if (block.headers && block.headers.length > 0) {
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
const headerTr = document.createElement('tr');
|
||||||
|
block.headers.forEach((hText, colIdx) => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
if (block.alignments && block.alignments[colIdx]) {
|
||||||
|
th.style.textAlign = block.alignments[colIdx];
|
||||||
|
}
|
||||||
|
renderInteractiveSpans(th, hText, hText, knownWordsMap);
|
||||||
|
headerTr.appendChild(th);
|
||||||
|
});
|
||||||
|
thead.appendChild(headerTr);
|
||||||
|
table.appendChild(thead);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.rows && block.rows.length > 0) {
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
block.rows.forEach(row => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
row.forEach((cellText, colIdx) => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
if (block.alignments && block.alignments[colIdx]) {
|
||||||
|
td.style.textAlign = block.alignments[colIdx];
|
||||||
|
}
|
||||||
|
renderInteractiveSpans(td, cellText, cellText, knownWordsMap);
|
||||||
|
tr.appendChild(td);
|
||||||
|
});
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
}
|
||||||
|
wrap.appendChild(table);
|
||||||
|
readerContentBody.appendChild(wrap);
|
||||||
} else if (block.type === 'heading') {
|
} else if (block.type === 'heading') {
|
||||||
const tag = `h${Math.min(4, Math.max(2, block.level))}`;
|
const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`;
|
||||||
const h = document.createElement(tag);
|
const h = document.createElement(tag);
|
||||||
|
if (block.align) h.style.textAlign = block.align;
|
||||||
renderInteractiveSpans(h, block.text, block.text, knownWordsMap);
|
renderInteractiveSpans(h, block.text, block.text, knownWordsMap);
|
||||||
readerContentBody.appendChild(h);
|
readerContentBody.appendChild(h);
|
||||||
} else if (block.type === 'blockquote') {
|
} else if (block.type === 'blockquote') {
|
||||||
const bq = document.createElement('blockquote');
|
const bq = document.createElement('blockquote');
|
||||||
const p = document.createElement('p');
|
renderInteractiveSpans(bq, block.text, block.text, knownWordsMap);
|
||||||
renderInteractiveSpans(p, block.text, block.text, knownWordsMap);
|
|
||||||
bq.appendChild(p);
|
|
||||||
readerContentBody.appendChild(bq);
|
readerContentBody.appendChild(bq);
|
||||||
} else if (block.type === 'ul' || block.type === 'ol') {
|
} else if (block.type === 'ul' || block.type === 'ol') {
|
||||||
const listEl = document.createElement(block.type);
|
const listEl = document.createElement(block.type);
|
||||||
@@ -1912,6 +1950,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
} else {
|
} else {
|
||||||
// paragraph
|
// paragraph
|
||||||
const p = document.createElement('p');
|
const p = document.createElement('p');
|
||||||
|
if (block.align) {
|
||||||
|
p.style.textAlign = block.align;
|
||||||
|
p.classList.add('text-center');
|
||||||
|
}
|
||||||
renderInteractiveSpans(p, block.text, block.text, knownWordsMap);
|
renderInteractiveSpans(p, block.text, block.text, knownWordsMap);
|
||||||
readerContentBody.appendChild(p);
|
readerContentBody.appendChild(p);
|
||||||
}
|
}
|
||||||
@@ -1964,7 +2006,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Headings: #, ##, ###, ####, #####, ######
|
// 3. Markdown Table: lines starting with | and ending with |
|
||||||
|
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
|
||||||
|
const tableLines = [];
|
||||||
|
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
||||||
|
tableLines.push(lines[i].trim());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (tableLines.length >= 2) {
|
||||||
|
const splitRow = (rowStr) => {
|
||||||
|
const inner = rowStr.replace(/^\|/, '').replace(/\|$/, '');
|
||||||
|
return inner.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
|
||||||
|
};
|
||||||
|
const headers = splitRow(tableLines[0]);
|
||||||
|
const alignRow = splitRow(tableLines[1]);
|
||||||
|
const alignments = alignRow.map(col => {
|
||||||
|
const tc = col.trim();
|
||||||
|
if (tc.startsWith(':') && tc.endsWith(':')) return 'center';
|
||||||
|
if (tc.endsWith(':')) return 'right';
|
||||||
|
return 'left';
|
||||||
|
});
|
||||||
|
const rows = [];
|
||||||
|
for (let r = 2; r < tableLines.length; r++) {
|
||||||
|
rows.push(splitRow(tableLines[r]));
|
||||||
|
}
|
||||||
|
blocks.push({
|
||||||
|
type: 'table',
|
||||||
|
headers: headers,
|
||||||
|
alignments: alignments,
|
||||||
|
rows: rows
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Markdown Headings: #, ##, ###, ####, #####, ######
|
||||||
const headingMatch = line.match(/^(\#{1,6})\s+(.+)$/);
|
const headingMatch = line.match(/^(\#{1,6})\s+(.+)$/);
|
||||||
if (headingMatch) {
|
if (headingMatch) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
@@ -1976,14 +2052,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Horizontal Rule: --- or *** or ___
|
// HTML Heading: <h1...>...</h1>
|
||||||
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed)) {
|
const htmlHeadingMatch = line.match(/^<h([1-6])([^>]*)>(.*?)<\/h\1>/i);
|
||||||
|
if (htmlHeadingMatch) {
|
||||||
|
const level = parseInt(htmlHeadingMatch[1], 10);
|
||||||
|
const attrs = htmlHeadingMatch[2] || '';
|
||||||
|
const hText = htmlHeadingMatch[3].trim();
|
||||||
|
const align = /align=["']?center["']?/i.test(attrs) ? 'center' : '';
|
||||||
|
blocks.push({
|
||||||
|
type: 'heading',
|
||||||
|
level: level,
|
||||||
|
text: hText,
|
||||||
|
align: align
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Horizontal Rule: --- or *** or ___ or <hr>
|
||||||
|
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '<hr>' || trimmed === '<hr/>' || trimmed === '<hr />') {
|
||||||
blocks.push({ type: 'hr' });
|
blocks.push({ type: 'hr' });
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Blockquote: > ...
|
// 6. Blockquote: > ...
|
||||||
if (trimmed.startsWith('>')) {
|
if (trimmed.startsWith('>')) {
|
||||||
const quoteLines = [];
|
const quoteLines = [];
|
||||||
while (i < lines.length && lines[i].trim().startsWith('>')) {
|
while (i < lines.length && lines[i].trim().startsWith('>')) {
|
||||||
@@ -1997,7 +2090,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Unordered List: - item or * item or + item
|
// 7. Unordered List: - item or * item or + item
|
||||||
if (/^[-*+]\s+/.test(trimmed)) {
|
if (/^[-*+]\s+/.test(trimmed)) {
|
||||||
const listItems = [];
|
const listItems = [];
|
||||||
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
||||||
@@ -2011,7 +2104,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Ordered List: 1. item
|
// 8. Ordered List: 1. item
|
||||||
if (/^\d+\.\s+/.test(trimmed)) {
|
if (/^\d+\.\s+/.test(trimmed)) {
|
||||||
const listItems = [];
|
const listItems = [];
|
||||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||||
@@ -2025,7 +2118,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Regular paragraph: gather lines until next block marker or empty line
|
// 9. Centered HTML block (<p align="center"> or <div align="center">)
|
||||||
|
if (/^<(?:p|div)[^>]*align=["']?center["']?[^>]*>/i.test(trimmed)) {
|
||||||
|
const htmlLines = [];
|
||||||
|
while (i < lines.length) {
|
||||||
|
const cur = lines[i];
|
||||||
|
htmlLines.push(cur.trim());
|
||||||
|
if (/<\/(?:p|div)>/i.test(cur)) {
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
text: htmlLines.join(' '),
|
||||||
|
align: 'center'
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Regular paragraph: gather lines until next block marker or empty line
|
||||||
const paraLines = [];
|
const paraLines = [];
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const cur = lines[i];
|
const cur = lines[i];
|
||||||
@@ -2033,10 +2146,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!curTrim) break;
|
if (!curTrim) break;
|
||||||
if (curTrim.startsWith('```') ||
|
if (curTrim.startsWith('```') ||
|
||||||
/^#{1,6}\s+/.test(curTrim) ||
|
/^#{1,6}\s+/.test(curTrim) ||
|
||||||
|
/^<h[1-6][^>]*>/i.test(curTrim) ||
|
||||||
|
(curTrim.startsWith('|') && curTrim.endsWith('|')) ||
|
||||||
curTrim.startsWith('>') ||
|
curTrim.startsWith('>') ||
|
||||||
/^[-*+]\s+/.test(curTrim) ||
|
/^[-*+]\s+/.test(curTrim) ||
|
||||||
/^\d+\.\s+/.test(curTrim) ||
|
/^\d+\.\s+/.test(curTrim) ||
|
||||||
/^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim)) {
|
/^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) ||
|
||||||
|
/^<(?:p|div)[^>]*align=["']?center/i.test(curTrim)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
paraLines.push(curTrim);
|
paraLines.push(curTrim);
|
||||||
@@ -2053,47 +2169,160 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render text with inline formatting and interactive clickable word spans
|
// Render text with full inline Markdown/HTML formatting and interactive clickable word spans
|
||||||
function renderInteractiveSpans(container, text, contextText, knownWordsMap) {
|
function renderInteractiveSpans(container, text, contextText, knownWordsMap) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const inlineCodeParts = text.split(/(`[^`]+`)/);
|
|
||||||
inlineCodeParts.forEach(part => {
|
// 1. Normalize entities & clean non-semantic wrapper tags
|
||||||
if (part.startsWith('`') && part.endsWith('`') && part.length >= 2) {
|
let processed = text
|
||||||
const codeNode = document.createElement('code');
|
.replace(/·/g, '·')
|
||||||
codeNode.className = 'reader-inline-code';
|
.replace(/•/g, '•')
|
||||||
codeNode.textContent = part.slice(1, -1);
|
.replace(/—/g, '—')
|
||||||
container.appendChild(codeNode);
|
.replace(/–/g, '–');
|
||||||
return;
|
|
||||||
|
// Strip enclosing <p...> and </p> tags if present
|
||||||
|
processed = processed.replace(/^<p[^>]*>/i, '').replace(/<\/p>$/i, '');
|
||||||
|
// Strip wrapper picture and source tags
|
||||||
|
processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, '');
|
||||||
|
|
||||||
|
// 2. Convert Markdown inline patterns into HTML
|
||||||
|
// Images:  -> <img src="url" alt="alt">
|
||||||
|
processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
|
||||||
|
// Links: [text](url) -> <a href="url">$1</a>
|
||||||
|
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||||
|
// Inline Code: `code` -> <code class="reader-inline-code">$1</code>
|
||||||
|
processed = processed.replace(/`([^`]+)`/g, '<code class="reader-inline-code">$1</code>');
|
||||||
|
// Bold: **text** or __text__ -> <strong>text</strong>
|
||||||
|
processed = processed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
processed = processed.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||||
|
// Italic: *text* or _text_ -> <em>text</em>
|
||||||
|
processed = processed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||||||
|
processed = processed.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// 3. Parse into DOM tree
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(`<body>${processed}</body>`, 'text/html');
|
||||||
|
|
||||||
|
// 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words
|
||||||
|
function transformDOMNode(node) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const raw = node.textContent;
|
||||||
|
if (!raw) return document.createTextNode('');
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const tokens = raw.split(/([A-Za-z0-9'-]+)/);
|
||||||
|
for (const token of tokens) {
|
||||||
|
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 || raw, span);
|
||||||
|
});
|
||||||
|
|
||||||
|
frag.appendChild(span);
|
||||||
|
} else {
|
||||||
|
frag.appendChild(document.createTextNode(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = part.split(/([A-Za-z0-9'-]+)/);
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
tokens.forEach(token => {
|
const tag = node.tagName.toLowerCase();
|
||||||
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();
|
// Code and pre: keep as code badge, DO NOT tokenize words!
|
||||||
if (knownWordsMap && knownWordsMap.has(lower)) {
|
if (tag === 'code' || tag === 'pre') {
|
||||||
const card = knownWordsMap.get(lower);
|
const codeEl = document.createElement('code');
|
||||||
span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
|
codeEl.className = 'reader-inline-code';
|
||||||
}
|
codeEl.textContent = node.textContent;
|
||||||
if (isHighlightEnabled) {
|
return codeEl;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Images
|
||||||
|
if (tag === 'img') {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'reader-img';
|
||||||
|
img.src = node.getAttribute('src') || '';
|
||||||
|
img.alt = node.getAttribute('alt') || '';
|
||||||
|
if (node.getAttribute('width')) img.setAttribute('width', node.getAttribute('width'));
|
||||||
|
if (node.getAttribute('height')) img.setAttribute('height', node.getAttribute('height'));
|
||||||
|
// Hide gracefully if broken/local relative asset
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
img.style.display = 'none';
|
||||||
|
});
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links
|
||||||
|
if (tag === 'a') {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'reader-link';
|
||||||
|
a.href = node.getAttribute('href') || '#';
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
Array.from(node.childNodes).forEach(child => {
|
||||||
|
a.appendChild(transformDOMNode(child));
|
||||||
|
});
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'br') {
|
||||||
|
return document.createElement('br');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'strong' || tag === 'b') {
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
Array.from(node.childNodes).forEach(child => {
|
||||||
|
strong.appendChild(transformDOMNode(child));
|
||||||
|
});
|
||||||
|
return strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'em' || tag === 'i') {
|
||||||
|
const em = document.createElement('em');
|
||||||
|
Array.from(node.childNodes).forEach(child => {
|
||||||
|
em.appendChild(transformDOMNode(child));
|
||||||
|
});
|
||||||
|
return em;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'sub' || tag === 'sup') {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
Array.from(node.childNodes).forEach(child => {
|
||||||
|
el.appendChild(transformDOMNode(child));
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic containers (span, div, etc.)
|
||||||
|
const containerEl = document.createElement(tag === 'p' ? 'span' : tag);
|
||||||
|
if (node.hasAttribute('style')) {
|
||||||
|
containerEl.setAttribute('style', node.getAttribute('style'));
|
||||||
|
}
|
||||||
|
Array.from(node.childNodes).forEach(child => {
|
||||||
|
containerEl.appendChild(transformDOMNode(child));
|
||||||
|
});
|
||||||
|
return containerEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.createTextNode('');
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(doc.body.childNodes).forEach(childNode => {
|
||||||
|
container.appendChild(transformDOMNode(childNode));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user