/** * Markdown Viewer — Client-side functionality * Version: 2.7.0 * Author: Mikhail Deynekin * Site: https://Deynekin.com * Email: Mikhail@Deynekin.com * * Features: * - Theme toggle with localStorage persistence + system sync * - Width control (reading/article/wide) with persistence * - Mermaid diagram initialization * - Unified copy-to-clipboard controls for code blocks, blockquotes and the * whole source file * - Mermaid auto-repair with English console diagnostics * - File browser: debounced search, tri-state sort with date columns opening * newest-first, panel-chosen default sort, click/keyboard open * * v2.7.0: The table is sorted on load by the default chosen in the settings * panel, and the panel can re-apply that choice without a reload. The * default itself (Modified, newest first) lives in md.php, which renders * it onto the table as data-sort-default / data-sort-dir-default, so the * table carries the value it is ordered by rather than a copy of it. * sessionStorage then cookie, read with the same two key names the panel * writes (mdv_sort, mdv_sort-dir) - the scripts share no state, so those * names are the whole interface between them and are mirrored in * js/md/settings.js. An unknown or absent value changes nothing: the * server's order stands, and a click still cycles first-direction -> * opposite -> off as before. * v2.6.1: The Created and Modified columns of the file browser open * newest-first on the first click. They used to open oldest-first like * every other column, so the one order a date column exists to give * cost a second click. File, Dir and Size are unchanged, and each * column still cycles first-direction -> opposite -> off; the direction * off returns to is still the original path order. * v2.6.0: A copy control may carry the URL of the file it copies * (data-copy-source) instead of a rendered block. Its text is requested * from the server on click, from the same request the download control * uses, so the page does not carry the source a second time. Without * this branch such a control matches the delegated handler and then * returns silently: it looks wired and does nothing. * * The parsed-fragment field of the Mermaid diagnostic report is named * tokenText: the previous name read like a credential assignment, which * is noise in reviewers' tooling and in secret scanners. The value is a * diagram fragment from mermaid.parse() and never a credential. * v2.5.3: File rows open an absolute URL spelled out from the page origin with * duplicate slashes in the path collapsed. A page opened at a path such * as //extensions/... used to produce a link starting with //, which the * browser read as a protocol-relative URL and treated as host * "extensions", so the opened document lost the real domain. * v2.5.2: Added classDiagram note conversion, made every application and * console message English, and made Mermaid diagnostics unconditional. * v2.5.0: Added Mermaid auto-repair with a grouped console report per diagram. * v2.4.0: Unified code and quote copying behind one copy-btn handler. * v2.3.0: Added one accessible copy control per rendered blockquote. * v2.2.1: Width selection persisted and re-applied to every width target; * hardened theme/listener guards; const-grouped selectors; safer * clipboard fallback; defensive null checks throughout. */ (() => { 'use strict'; const root = document.documentElement; // Absolute URL of this page: the origin the page was opened from plus its // path with duplicate slashes collapsed. Spelling the origin out keeps // generated links on the real host; a bare pathname such as // //extensions/... would be read as a protocol-relative URL and the first // path segment would become the host. const pageUrl = () => window.location.origin + window.location.pathname.replace(/\/{2,}/g, '/'); const THEME_KEY = 'radio-viewer-theme'; const themeBtn = document.querySelector('[data-theme-toggle]'); const themeIcon = document.querySelector('[data-theme-icon]'); // ============================================================ // Theme management // ============================================================ const getPreferredTheme = () => { const stored = localStorage.getItem(THEME_KEY); if (stored === 'light' || stored === 'dark') { return stored; } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }; const applyTheme = (theme) => { root.setAttribute('data-theme', theme); root.classList.toggle('dark', theme === 'dark'); if (themeIcon) { themeIcon.innerHTML = theme === 'dark' ? '' : ''; } themeBtn?.setAttribute( 'aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme' ); }; applyTheme(getPreferredTheme()); themeBtn?.addEventListener('click', () => { const current = root.getAttribute('data-theme') || getPreferredTheme(); const next = current === 'dark' ? 'light' : 'dark'; localStorage.setItem(THEME_KEY, next); applyTheme(next); }); const media = window.matchMedia('(prefers-color-scheme: dark)'); const onSystemChange = (e) => { if (!localStorage.getItem(THEME_KEY)) { applyTheme(e.matches ? 'dark' : 'light'); } }; if (typeof media.addEventListener === 'function') { media.addEventListener('change', onSystemChange); } else if (typeof media.addListener === 'function') { media.addListener(onSystemChange); } // ============================================================ // Mermaid diagram initialization (lazy-load) // Loads Mermaid only when rendered Markdown contains
.
// ============================================================
const MDV_MERMAID_REPAIR_VERSION = '2.5.2';
const MERMAID_NODE_SHAPES = [['[[', ']]'], ['[(', ')]'], ['([', '])'], ['[', ']'], ['{{', '}}'], ['{', '}'], ['((', '))']];
const MERMAID_KEYWORD_LINE = /^(style|classDef|class|linkStyle|click|subgraph|end|graph|flowchart|direction|%%)\b/;
const MERMAID_SEQUENCE_ARROW = /^\s*([^\s:]+?)\s*(-->>|->>|-->|->|--x|-x|--\)|-\))\s*([^\s:]+?)\s*:/;
document.documentElement.dataset.mdvMermaidRepairVersion = MDV_MERMAID_REPAIR_VERSION;
console.info('[MD.Viewer] Mermaid repair engine v' + MDV_MERMAID_REPAIR_VERSION + ' loaded');
const getMermaidDiagramType = (source) => {
const header = source.split('\n').map((line) => line.trim()).find((line) => line !== '' && !line.startsWith('%%')) || '';
return header.split(/\s+/)[0];
};
const normalizeMermaidLine = (line) => {
let result = '';
let index = 0;
while (index < line.length) {
const idMatch = /^[A-Za-z_][\w-]*/.exec(line.slice(index));
if (idMatch === null) { result += line[index]; index += 1; continue; }
const nodeId = idMatch[0];
const afterId = index + nodeId.length;
let shape = null;
for (const [open, close] of MERMAID_NODE_SHAPES) {
if (!line.startsWith(open, afterId)) continue;
const closeIndex = line.indexOf(close, afterId + open.length);
if (closeIndex !== -1) { shape = { open, close, closeIndex }; break; }
}
if (shape === null) { result += nodeId; index = afterId; continue; }
const rawLabel = line.slice(afterId + shape.open.length, shape.closeIndex);
const trimmed = rawLabel.trim();
const needsQuotes = trimmed !== '' && !/^["`]/.test(trimmed) && /[()]/.test(trimmed);
result += nodeId + shape.open + (needsQuotes ? '"' + trimmed.replace(/"/g, '#quot;') + '"' : rawLabel) + shape.close;
index = shape.closeIndex + shape.close.length;
}
return result;
};
const repairClassDiagramNotes = (source, fixes) => {
const lines = source.split('\n');
const result = [];
for (let index = 0; index < lines.length; index += 1) {
const match = /^(\s*)note\s+(?:right|left)\s+of\s+([A-Za-z_][\w-]*)\s*$/.exec(lines[index]);
if (match === null) {
result.push(lines[index]);
continue;
}
const noteLines = [];
let endIndex = index + 1;
while (endIndex < lines.length && !/^\s*end\s+note\s*$/.test(lines[endIndex])) {
const text = lines[endIndex].trim();
if (text !== '') noteLines.push(text);
endIndex += 1;
}
if (endIndex >= lines.length) {
result.push(lines[index]);
continue;
}
const noteText = noteLines.join('
').replace(/"/g, '#quot;');
const replacement = match[1] + 'note for ' + match[2] + ' "' + noteText + '"';
fixes.push({
line: index + 1,
rule: 'plantuml-class-note',
detail: 'PlantUML note block converted to Mermaid note-for syntax',
before: lines[index].trim() + ' ... end note',
after: replacement.trim()
});
result.push(replacement);
index = endIndex;
}
return result.join('\n');
};
const repairMermaidSource = (source) => {
const fixes = [];
const diagramType = getMermaidDiagramType(source);
const isSequence = diagramType === 'sequenceDiagram';
const isFlowchart = diagramType === 'graph' || diagramType === 'flowchart';
let prepared = source.replace(/\r\n?/g, '\n');
if (diagramType === 'classDiagram') {
prepared = repairClassDiagramNotes(prepared, fixes);
}
let lastCaller = null;
let lastCallee = null;
const repaired = prepared.split('\n').map((line, position) => {
const lineNumber = position + 1;
const original = line;
let current = line;
if (current.includes('\\n')) {
current = current.split('\\n').join('
');
fixes.push({ line: lineNumber, rule: 'literal-newline', detail: 'Literal \\n replaced with
', before: original.trim(), after: current.trim() });
}
if (/
/i.test(current)) {
const before = current;
current = current.replace(/
/gi, '
');
fixes.push({ line: lineNumber, rule: 'unclosed-br', detail: '
replaced with
', before: before.trim(), after: current.trim() });
}
if (isSequence) {
const arrow = MERMAID_SEQUENCE_ARROW.exec(current);
if (arrow !== null) { lastCaller = arrow[1]; lastCallee = arrow[3]; }
const returnMatch = /^(\s*)return\b\s*(.*)$/.exec(current);
if (returnMatch !== null) {
const before = current;
const message = returnMatch[2].trim();
if (lastCaller !== null && lastCallee !== null) {
current = returnMatch[1] + lastCallee + '-->>' + lastCaller + ': ' + (message === '' ? 'return' : message);
fixes.push({ line: lineNumber, rule: 'plantuml-return', detail: 'PlantUML return converted to ' + lastCallee + '-->>' + lastCaller, before: before.trim(), after: current.trim() });
} else {
current = returnMatch[1] + '%% ' + before.trim();
fixes.push({ line: lineNumber, rule: 'plantuml-return-orphan', detail: 'Orphan PlantUML return commented out', before: before.trim(), after: current.trim() });
}
}
} else if (isFlowchart && !MERMAID_KEYWORD_LINE.test(current.trim())) {
const normalized = normalizeMermaidLine(current);
if (normalized !== current) {
fixes.push({ line: lineNumber, rule: 'unquoted-parentheses', detail: 'Node label containing parentheses was quoted', before: current.trim(), after: normalized.trim() });
current = normalized;
}
}
return current;
}).join('\n');
return { source: repaired, fixes, diagramType };
};
const extractMermaidError = (error) => {
if (!error) return { message: 'mermaid.parse() returned false', line: null, tokenText: null };
const hash = error.hash || error.error?.hash || null;
return { message: error.message || error.str || String(error), line: hash?.loc?.first_line || hash?.line || null, tokenText: hash?.token || hash?.text || null };
};
const reportMermaidDiagnostics = (index, repair, isValid, parseError = null) => {
const label = '[MD.Viewer] Mermaid #' + (index + 1) + ' (' + repair.diagramType + ')' + (isValid ? ' — ready' : ' — syntax error after auto-repair');
console.group(label);
console.info('Repair engine:', MDV_MERMAID_REPAIR_VERSION);
console.info('Applied fixes:', repair.fixes.length);
if (repair.fixes.length > 0) console.table(repair.fixes, ['line', 'rule', 'detail', 'before', 'after']);
if (!isValid) {
const details = extractMermaidError(parseError);
console.error('Mermaid parse failed:', details.message);
if (details.line !== null) console.error('Line:', details.line, 'Token:', details.tokenText);
console.debug('Repaired source:\n' + repair.source);
}
console.groupEnd();
};
const initMermaidIfNeeded = async () => {
const mermaidBlocks = [...document.querySelectorAll('pre.mermaid')];
console.info('[MD.Viewer] Mermaid blocks found:', mermaidBlocks.length);
if (mermaidBlocks.length === 0) return;
try {
const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
mermaid.initialize({ startOnLoad: false, theme: root.getAttribute('data-theme') === 'dark' ? 'dark' : 'default', securityLevel: 'loose', suppressErrorRendering: true, flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' } });
for (const [index, node] of mermaidBlocks.entries()) {
const originalSource = node.textContent || '';
const repair = repairMermaidSource(originalSource);
node.dataset.mermaidRepairVersion = MDV_MERMAID_REPAIR_VERSION;
node.dataset.mermaidFixes = String(repair.fixes.length);
node.dataset.mermaidSource = originalSource;
node.textContent = repair.source;
let isValid = false;
let parseError = null;
try { isValid = await mermaid.parse(repair.source, { suppressErrors: true }) !== false; }
catch (error) { parseError = error; }
reportMermaidDiagnostics(index, repair, isValid, parseError);
if (!isValid) {
node.classList.add('mermaid-error');
node.setAttribute('data-mermaid-error', 'true');
continue;
}
const wrapper = document.createElement('div');
wrapper.className = 'mermaid';
wrapper.textContent = repair.source;
wrapper.dataset.mermaidSource = originalSource;
wrapper.dataset.mermaidFixes = String(repair.fixes.length);
wrapper.dataset.mermaidRepairVersion = MDV_MERMAID_REPAIR_VERSION;
wrapper.setAttribute('data-mermaid-id', 'mermaid-' + index);
node.replaceWith(wrapper);
try {
await mermaid.run({ nodes: [wrapper], suppressErrors: true });
console.info('[MD.Viewer] Mermaid #' + (index + 1) + ' rendered successfully');
} catch (error) {
console.error('[MD.Viewer] Mermaid #' + (index + 1) + ' render failed:', error);
wrapper.replaceWith(node);
node.classList.add('mermaid-error');
node.setAttribute('data-mermaid-error', 'true');
}
}
} catch (error) {
console.error('[MD.Viewer] Mermaid module initialization failed:', error);
}
};
const getQuoteText = (blockquote) => {
const clone = blockquote.cloneNode(true);
clone.querySelectorAll('.copy-btn').forEach((button) => button.remove());
return [...clone.childNodes].map((node) => {
const text = node.nodeType === Node.ELEMENT_NODE ? (node.innerText || node.textContent || '') : (node.textContent || '');
return text.trim();
}).filter(Boolean).join('\n\n');
};
const initQuoteBlocks = () => {
document.querySelectorAll('blockquote').forEach((blockquote) => {
blockquote.classList.add('quote-block');
blockquote.setAttribute('data-quote-block', '');
});
};
initQuoteBlocks();
window.addEventListener('load', () => {
initMermaidIfNeeded();
}, { once: true });
// ============================================================
// Copy-to-clipboard (event delegation)
// ============================================================
const fallbackCopy = (text) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0';
textarea.setAttribute('readonly', '');
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
let ok = false;
try {
ok = document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
if (!ok) {
throw new Error('execCommand copy failed');
}
};
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.copy-btn');
if (!btn || btn.disabled) {
return;
}
const quote = btn.closest('blockquote[data-quote-block]');
const wrapper = btn.closest('.code-block-wrapper');
const mermaidElement = wrapper?.querySelector('.mermaid[data-mermaid-source], pre.mermaid[data-mermaid-source]');
const codeElement = wrapper?.querySelector('pre code, pre.mermaid');
let text = quote ? getQuoteText(quote) : (mermaidElement?.dataset.mermaidSource || codeElement?.textContent || '');
let subject = quote ? 'Quote' : 'Code';
const copyIcon = btn.querySelector('.copy-icon');
const checkIcon = btn.querySelector('.check-icon');
const copyText = btn.querySelector('.copy-text');
const checkText = btn.querySelector('.check-text');
const statusEl = document.getElementById('copy-status');
const announce = (message) => {
if (statusEl) {
statusEl.textContent = message;
setTimeout(() => { statusEl.textContent = ''; }, 3000);
}
};
const resetButton = () => {
copyIcon?.classList.remove('hidden');
checkIcon?.classList.add('hidden');
copyText?.classList.remove('hidden');
checkText?.classList.add('hidden');
btn.disabled = false;
btn.classList.remove('opacity-75', 'text-green-400');
};
// A toolbar control carries the URL of the file to copy instead of a
// rendered block: the page must not hold a second copy of the document,
// so the text is requested on click. The URL is the download request,
// which the viewer validates on arrival.
if (!text && btn.dataset.copySource) {
subject = 'File';
// Disabled before the request, not after it: the button is disarmed
// at the top of this handler, but the usual arming point sits below
// the awaits, so without this a second click during the request
// would start a second one.
btn.disabled = true;
btn.classList.add('opacity-75');
try {
const response = await fetch(btn.dataset.copySource);
text = response.ok ? await response.text() : '';
} catch (err) {
console.error('Copy source request failed:', err);
text = '';
}
}
if (!text) {
// Release the button in every case: a failed request must not leave
// a permanently disarmed control, and a block with no text used to
// return silently. Saying so is the difference between a control
// that did nothing and one that says it has nothing to do.
resetButton();
announce('Nothing to copy here');
return;
}
const showSuccess = () => {
copyIcon?.classList.add('hidden');
checkIcon?.classList.remove('hidden');
copyText?.classList.add('hidden');
checkText?.classList.remove('hidden');
btn.classList.remove('opacity-75');
btn.classList.add('text-green-400');
announce(subject + ' copied to clipboard');
setTimeout(resetButton, 2000);
};
btn.disabled = true;
btn.classList.add('opacity-75');
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}
showSuccess();
} catch (err) {
console.error('Copy to clipboard failed:', err);
resetButton();
announce('Failed to copy ' + subject.toLowerCase() + '. Please select and copy manually.');
}
});
// ============================================================
// File browser: search, sort, open
// ============================================================
const filesTable = document.getElementById('files-table');
const searchInput = document.getElementById('files-search');
if (!filesTable || !searchInput) {
return;
}
const tbody = filesTable.querySelector('tbody');
if (!tbody) {
return;
}
const rows = [...tbody.querySelectorAll('tr.file-row')];
const headers = [...filesTable.querySelectorAll('th.sortable')];
let currentSort = { key: null, direction: null };
let searchDebounceTimer = null;
const performSearch = (query) => {
const q = query.trim().toLowerCase();
let visibleCount = 0;
rows.forEach((row) => {
if (q === '') {
row.classList.remove('filtered-out');
visibleCount++;
return;
}
const fileName = row.dataset.file || '';
const dirName = row.dataset.dir || '';
const matches = fileName.includes(q) || dirName.includes(q);
row.classList.toggle('filtered-out', !matches);
if (matches) {
visibleCount++;
}
});
const noResults = document.getElementById('no-results-msg');
if (noResults) {
noResults.style.display = (visibleCount === 0 && q !== '') ? 'block' : 'none';
}
};
searchInput.addEventListener('input', (e) => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => performSearch(e.target.value), 150);
});
const NUMERIC_KEYS = ['size', 'created', 'modified'];
const compareRows = (a, b, key, direction) => {
if (NUMERIC_KEYS.includes(key)) {
const valA = parseInt(a.dataset[key], 10) || 0;
const valB = parseInt(b.dataset[key], 10) || 0;
return direction === 'asc' ? valA - valB : valB - valA;
}
const valA = a.dataset[key] || '';
const valB = b.dataset[key] || '';
const cmp = valA.localeCompare(valB, undefined, { sensitivity: 'base', numeric: true });
return direction === 'asc' ? cmp : -cmp;
};
const reorder = (sortedRows) => {
const fragment = document.createDocumentFragment();
sortedRows.forEach((row) => fragment.appendChild(row));
tbody.appendChild(fragment);
};
// Direction a column selects on its first click. A date column (Created,
// Modified) opens newest-first: ascending would put the newest document
// last and make the reader click twice for the order the column exists to
// give. File, Dir and Size keep the ascending default.
const FIRST_DIRECTION = { created: 'desc', modified: 'desc' };
const SORT_KEYS = ['file', 'dir', 'created', 'modified', 'size'];
const SORT_DIRECTIONS = ['asc', 'desc'];
const applySort = (key, direction) => {
headers.forEach((th) => {
th.classList.remove('asc', 'desc');
if (th.dataset.sort === key && direction) {
th.classList.add(direction);
}
});
currentSort = { key: direction ? key : null, direction };
if (!direction) {
reorder([...rows].sort((a, b) =>
(a.dataset.path || '').localeCompare(b.dataset.path || '')
));
return;
}
reorder([...rows].sort((a, b) => compareRows(a, b, key, direction)));
};
const sortTable = (key) => {
const first = FIRST_DIRECTION[key] ?? 'asc';
let newDirection;
if (currentSort.key !== key) {
newDirection = first;
} else if (currentSort.direction === first) {
newDirection = first === 'asc' ? 'desc' : 'asc';
} else {
newDirection = null;
}
applySort(key, newDirection);
};
headers.forEach((th) => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (key) {
sortTable(key);
}
});
});
// ── Default sort, chosen in the settings panel ────────────────────────────
// The panel stores mdv_sort / mdv_sort-dir through its own storage helper
// (store/load in js/md/settings.js): the two scripts share no state, so the
// stored value is the only channel between them and these two key names are
// mirrored there. sessionStorage first, cookie second, exactly as that
// helper reads it, so a preference that survives only as a cookie still
// reaches the table in a fresh browser session.
const readSortPref = (key) => {
const sessionValue = sessionStorage.getItem(key);
if (sessionValue !== null) {
return sessionValue;
}
const pattern = new RegExp(
'(?:^|;)\\s*' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'
);
const match = document.cookie.match(pattern);
return match ? decodeURIComponent(match[1]) : null;
};
// A stored preference wins; otherwise the shipped default, authored once in
// md.php and rendered onto the table it orders. An absent attribute (a
// viewer paired with an older table) leaves the server's own order alone.
const storedSortKey = readSortPref('mdv_sort');
const storedSortDir = readSortPref('mdv_sort-dir');
const initialSortKey = SORT_KEYS.includes(storedSortKey)
? storedSortKey
: filesTable.dataset.sortDefault;
const initialSortDir = SORT_DIRECTIONS.includes(storedSortDir)
? storedSortDir
: filesTable.dataset.sortDirDefault;
if (SORT_KEYS.includes(initialSortKey) && SORT_DIRECTIONS.includes(initialSortDir)) {
applySort(initialSortKey, initialSortDir);
}
// The panel announces a change instead of reloading the page; the table is
// the only owner of the order, so it is applied here and nowhere else.
document.addEventListener('mdv:sort', (event) => {
const { key, direction } = event.detail || {};
if (SORT_KEYS.includes(key) && SORT_DIRECTIONS.includes(direction)) {
applySort(key, direction);
}
});
tbody.addEventListener('click', (e) => {
if (e.target.closest('th')) {
return;
}
const row = e.target.closest('tr.file-row');
const path = row?.dataset.path;
if (!path) {
return;
}
// Defense in depth: reject control chars and traversal/absolute paths.
if (/[\x00-\x1F\x7F]/.test(path) || /\.\.(?:\/|$)|^\//.test(path)) {
return;
}
const url = pageUrl() + '?file=' + encodeURIComponent(path);
window.open(url, '_blank', 'noopener,noreferrer');
});
rows.forEach((row) => {
row.setAttribute('tabindex', '0');
row.setAttribute('role', 'button');
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
row.click();
}
});
});
})();
// ── Inline code: click to copy ───────────────────────────────────────────────
// Reuses the existing navigator.clipboard API, consistent with copy-btn logic.
document.addEventListener('click', (e) => {
const el = e.target.closest('code.copy-on-click');
if (!el) return;
const text = el.textContent ?? '';
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
// Brief visual feedback — reuse the same "copied" style as copy-btn
el.classList.add('copied');
const prev = el.title;
el.title = 'Copied!';
setTimeout(() => {
el.classList.remove('copied');
el.title = prev;
}, 1500);
}).catch(() => {
// Fallback for HTTP contexts without clipboard API
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel?.removeAllRanges();
sel?.addRange(range);
});
});