feat(reader): full Markdown and HTML rendering for GitHub READMEs, tables, images and links
This commit is contained in:
@@ -21,12 +21,21 @@
|
||||
- **Слова внутри списков и цитат остаются кликабельными** (интерактивный клик-перевод работает!).
|
||||
- [x] **Заголовки Markdown (`##`, `###`)**:
|
||||
- Конвертация в `<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)**:
|
||||
- Исправлен баг двойного смещения координат: убран `window.scrollY` при `position: fixed`.
|
||||
- Окно позиционируется точно под кликнутым словом (`rect.bottom + 8`) либо над ним (`rect.top - popupHeight - 8`), если снизу недостаточно места.
|
||||
- Окно центрируется относительно кликнутого слова с проверкой границ вьюпорта (отступы минимум 12px от краёв).
|
||||
- Окно перенесено в корень `<body>` для исключения искажений контекста позиционирования.
|
||||
- Плавное отслеживание скролла через `requestAnimationFrame` с автозакрытием при уходе слова за границы экрана или под липкую шапку.
|
||||
- [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")
|
||||
first_line = lines[0].strip()
|
||||
|
||||
# Extract title from first line if default
|
||||
if title == "Untitled":
|
||||
# Extract title from first line or # Heading / <h1...> if default or generic README
|
||||
if title == "Untitled" or title.lower().startswith("readme"):
|
||||
if first_line.startswith("# "):
|
||||
title = first_line.lstrip("# ").strip()
|
||||
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()
|
||||
if len(lines) > 1:
|
||||
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
|
||||
if not author:
|
||||
|
||||
@@ -2808,6 +2808,140 @@ body {
|
||||
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 */
|
||||
.reader-word {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||
<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 -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -922,6 +922,6 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<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>
|
||||
</html>
|
||||
|
||||
+256
-27
@@ -1888,16 +1888,54 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
codeBlock.appendChild(header);
|
||||
codeBlock.appendChild(pre);
|
||||
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') {
|
||||
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);
|
||||
if (block.align) h.style.textAlign = block.align;
|
||||
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);
|
||||
renderInteractiveSpans(bq, block.text, block.text, knownWordsMap);
|
||||
readerContentBody.appendChild(bq);
|
||||
} else if (block.type === 'ul' || block.type === 'ol') {
|
||||
const listEl = document.createElement(block.type);
|
||||
@@ -1912,6 +1950,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} else {
|
||||
// paragraph
|
||||
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);
|
||||
readerContentBody.appendChild(p);
|
||||
}
|
||||
@@ -1964,7 +2006,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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+(.+)$/);
|
||||
if (headingMatch) {
|
||||
blocks.push({
|
||||
@@ -1976,14 +2052,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Horizontal Rule: --- or *** or ___
|
||||
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed)) {
|
||||
// HTML Heading: <h1...>...</h1>
|
||||
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' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Blockquote: > ...
|
||||
// 6. Blockquote: > ...
|
||||
if (trimmed.startsWith('>')) {
|
||||
const quoteLines = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith('>')) {
|
||||
@@ -1997,7 +2090,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6. Unordered List: - item or * item or + item
|
||||
// 7. Unordered List: - item or * item or + item
|
||||
if (/^[-*+]\s+/.test(trimmed)) {
|
||||
const listItems = [];
|
||||
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
||||
@@ -2011,7 +2104,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 7. Ordered List: 1. item
|
||||
// 8. Ordered List: 1. item
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const listItems = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
@@ -2025,7 +2118,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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 = [];
|
||||
while (i < lines.length) {
|
||||
const cur = lines[i];
|
||||
@@ -2033,10 +2146,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!curTrim) break;
|
||||
if (curTrim.startsWith('```') ||
|
||||
/^#{1,6}\s+/.test(curTrim) ||
|
||||
/^<h[1-6][^>]*>/i.test(curTrim) ||
|
||||
(curTrim.startsWith('|') && curTrim.endsWith('|')) ||
|
||||
curTrim.startsWith('>') ||
|
||||
/^[-*+]\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;
|
||||
}
|
||||
paraLines.push(curTrim);
|
||||
@@ -2053,21 +2169,48 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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) {
|
||||
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 => {
|
||||
// 1. Normalize entities & clean non-semantic wrapper tags
|
||||
let processed = text
|
||||
.replace(/·/g, '·')
|
||||
.replace(/•/g, '•')
|
||||
.replace(/—/g, '—')
|
||||
.replace(/–/g, '–');
|
||||
|
||||
// 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');
|
||||
@@ -2086,14 +2229,100 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
span.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
handleWordClick(token, contextText || text, span);
|
||||
handleWordClick(token, contextText || raw, span);
|
||||
});
|
||||
|
||||
container.appendChild(span);
|
||||
frag.appendChild(span);
|
||||
} else {
|
||||
container.appendChild(document.createTextNode(token));
|
||||
frag.appendChild(document.createTextNode(token));
|
||||
}
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
|
||||
// Code and pre: keep as code badge, DO NOT tokenize words!
|
||||
if (tag === 'code' || tag === 'pre') {
|
||||
const codeEl = document.createElement('code');
|
||||
codeEl.className = 'reader-inline-code';
|
||||
codeEl.textContent = node.textContent;
|
||||
return codeEl;
|
||||
}
|
||||
|
||||
// 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