fix(reader): optimize rendering performance, prevent browser freeze on large technical books

- extract_outline_entries: filter PDF bookmarks by depth and major structural headings, avoiding 550 micro-snippets
- renderContinuousBook: load current chapter and seamlessly append next chapters on scroll (virtual continuous) instead of dumping all chapters into DOM synchronously
- renderInteractiveSpans: use native HTML template fragment instead of new DOMParser per paragraph
- event delegation: handle word clicks on readerContentBody instead of adding hundreds of thousands of individual DOM event listeners
- consolidate existing CCNA book in DB into 68 clean chapters
This commit is contained in:
2026-10-06 11:23:21 +03:00
parent 46d6973365
commit c4a6edee37
3 changed files with 70 additions and 38 deletions
+28 -29
View File
@@ -2515,7 +2515,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!currentBook || !currentBook.chapters) return;
currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1));
readerChapterSelect.value = currentChapterIndex;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
@@ -2528,15 +2528,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
});
currentBook.chapters.forEach((_, idx) => {
loadedChaptersSet.add(idx);
const chFrag = buildChapterDOM(idx, knownWordsMap, idx > 0);
readerContentBody.appendChild(chFrag);
});
loadedChaptersSet.add(currentChapterIndex);
const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false);
readerContentBody.appendChild(chFrag);
const finalFooter = buildChapterFooterDOM(currentBook.chapters.length - 1);
if (finalFooter) {
readerContentBody.appendChild(finalFooter);
const footer = buildChapterFooterDOM(currentChapterIndex);
if (footer) {
readerContentBody.appendChild(footer);
}
applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex);
@@ -2871,9 +2869,9 @@ document.addEventListener('DOMContentLoaded', () => {
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');
// 3. Parse into DOM tree via native template fragment
const tpl = document.createElement('template');
tpl.innerHTML = processed;
// 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words
function transformDOMNode(node) {
@@ -2888,6 +2886,7 @@ document.addEventListener('DOMContentLoaded', () => {
const span = document.createElement('span');
span.className = 'reader-word';
span.dataset.word = token;
span.dataset.context = contextText || raw;
span.textContent = token;
const lower = token.toLowerCase();
@@ -2899,11 +2898,6 @@ document.addEventListener('DOMContentLoaded', () => {
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));
@@ -2993,7 +2987,7 @@ document.addEventListener('DOMContentLoaded', () => {
return document.createTextNode('');
}
Array.from(doc.body.childNodes).forEach(childNode => {
Array.from(tpl.content.childNodes).forEach(childNode => {
container.appendChild(transformDOMNode(childNode));
});
}
@@ -3453,13 +3447,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) {
currentChapterIndex = activeCh;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
if (readerChapterSelect && readerChapterSelect.options) {
for (let i = 0; i < readerChapterSelect.options.length; i++) {
const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`;
const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ ');
readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`;
}
}
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
@@ -3470,16 +3457,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// 2. Infinite scroll: auto-append next chapter in chapters mode
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) {
// 2. Infinite scroll: auto-append next chapter in continuous mode or chapters mode with autoload
if ((readerSettings.flowMode === 'continuous' || (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)) && !isAppendingChapter) {
const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight);
if (distanceToBottom < 450) {
if (distanceToBottom < 700) {
const loadedArr = Array.from(loadedChaptersSet);
const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex;
if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) {
isAppendingChapter = true;
appendNextChapter(highestCh + 1, false);
setTimeout(() => { isAppendingChapter = false; }, 300);
setTimeout(() => { isAppendingChapter = false; }, 200);
}
}
}
@@ -3832,6 +3819,18 @@ document.addEventListener('DOMContentLoaded', () => {
initReaderSettingsPanel();
if (readerContentBody) {
readerContentBody.addEventListener('click', (e) => {
const wordSpan = e.target.closest('.reader-word');
if (wordSpan) {
e.stopPropagation();
const word = wordSpan.dataset.word;
const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote')?.textContent || word;
handleWordClick(word, context, wordSpan);
}
});
}
if (readerToggleHighlightBtn) {
readerToggleHighlightBtn.addEventListener('click', () => {
isHighlightEnabled = !isHighlightEnabled;