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:
Antigravity committed 2026-10-06 11:23:21 +03:00
1 parent 46d6973365
commit c4a6edee37
3 files changed
+69 -37

No files matched your search

+41 -8
View File
@@ -513,15 +513,17 @@ CLI_PROMPT_RE = re.compile(
def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]: def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]:
""" """
Recursively extracts bookmarks / outline entries from PDF. Recursively extracts bookmarks / outline entries from PDF respecting hierarchy depth.
Returns a list of (title, 0-indexed page_number). For books with deep trees (Parts -> Chapters -> Sections -> Subsections),
prioritizes major structural headings and prevents fragmenting chapters into
hundreds of tiny 1-page snippets.
""" """
entries: List[Tuple[str, int]] = [] raw_entries: List[Tuple[str, int, int]] = []
def _traverse(items): def _traverse(items, depth=0):
for item in items: for item in items:
if isinstance(item, list): if isinstance(item, list):
_traverse(item) _traverse(item, depth + 1)
else: else:
try: try:
title = getattr(item, "title", None) or (item.get("/Title") if hasattr(item, "get") else None) title = getattr(item, "title", None) or (item.get("/Title") if hasattr(item, "get") else None)
@@ -529,13 +531,44 @@ def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]:
if title and page_num is not None: if title and page_num is not None:
clean_title = str(title).strip() clean_title = str(title).strip()
if clean_title: if clean_title:
entries.append((clean_title, int(page_num))) raw_entries.append((clean_title, int(page_num), depth))
except Exception: except Exception:
continue continue
if outline: if outline:
_traverse(outline) _traverse(outline, 0)
return entries
if not raw_entries:
return []
# If total entries is reasonable (<= 50), return all
if len(raw_entries) <= 50:
return [(t, p) for t, p, _ in raw_entries]
# Check depth distribution
d0_entries = [e for e in raw_entries if e[2] == 0]
d1_entries = [e for e in raw_entries if e[2] <= 1]
# If top-level (depth 0) already has >= 10 chapters, use depth 0
if len(d0_entries) >= 10:
return [(t, p) for t, p, _ in d0_entries]
# Otherwise, if depth <= 1 gives a good number of chapters (10 to 80), use depth <= 1
if len(d1_entries) <= 80:
return [(t, p) for t, p, _ in d1_entries]
# If still too many entries (> 80), filter by major chapter keywords
major_re = re.compile(
r"^(cover|title\s+page|copyright|contents|introduction|your\s+study\s+plan|"
r"part\s+[0-9ivxlcdm]+|chapter\s+\d+|appendix\s+[a-z]|glossary|index)",
re.IGNORECASE
)
major_entries = [e for e in d1_entries if major_re.match(e[0])]
if len(major_entries) >= 10:
return [(t, p) for t, p, _ in major_entries]
return [(t, p) for t, p, _ in d1_entries[:80]]
def clean_page_lines(raw_pages_text: List[str]) -> List[str]: def clean_page_lines(raw_pages_text: List[str]) -> List[str]:
+1 -1
View File
@@ -1075,7 +1075,7 @@
<!-- 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=3.9"></script> <script src="/static/js/app.js?v=4.0"></script>
</body> </body>
</html> </html>
+27 -28
View File
@@ -2515,7 +2515,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!currentBook || !currentBook.chapters) return; if (!currentBook || !currentBook.chapters) return;
currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1)); currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1));
readerChapterSelect.value = currentChapterIndex; if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex;
readerPrevChBtn.disabled = currentChapterIndex <= 0; readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
@@ -2528,15 +2528,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
}); });
currentBook.chapters.forEach((_, idx) => { loadedChaptersSet.add(currentChapterIndex);
loadedChaptersSet.add(idx); const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false);
const chFrag = buildChapterDOM(idx, knownWordsMap, idx > 0);
readerContentBody.appendChild(chFrag); readerContentBody.appendChild(chFrag);
});
const finalFooter = buildChapterFooterDOM(currentBook.chapters.length - 1); const footer = buildChapterFooterDOM(currentChapterIndex);
if (finalFooter) { if (footer) {
readerContentBody.appendChild(finalFooter); readerContentBody.appendChild(footer);
} }
applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex); 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>');
processed = processed.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>'); processed = processed.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
// 3. Parse into DOM tree // 3. Parse into DOM tree via native template fragment
const parser = new DOMParser(); const tpl = document.createElement('template');
const doc = parser.parseFromString(`<body>${processed}</body>`, 'text/html'); tpl.innerHTML = processed;
// 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words // 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words
function transformDOMNode(node) { function transformDOMNode(node) {
@@ -2888,6 +2886,7 @@ document.addEventListener('DOMContentLoaded', () => {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'reader-word'; span.className = 'reader-word';
span.dataset.word = token; span.dataset.word = token;
span.dataset.context = contextText || raw;
span.textContent = token; span.textContent = token;
const lower = token.toLowerCase(); const lower = token.toLowerCase();
@@ -2899,11 +2898,6 @@ document.addEventListener('DOMContentLoaded', () => {
span.classList.add('highlight-enabled'); span.classList.add('highlight-enabled');
} }
span.addEventListener('click', (e) => {
e.stopPropagation();
handleWordClick(token, contextText || raw, span);
});
frag.appendChild(span); frag.appendChild(span);
} else { } else {
frag.appendChild(document.createTextNode(token)); frag.appendChild(document.createTextNode(token));
@@ -2993,7 +2987,7 @@ document.addEventListener('DOMContentLoaded', () => {
return document.createTextNode(''); return document.createTextNode('');
} }
Array.from(doc.body.childNodes).forEach(childNode => { Array.from(tpl.content.childNodes).forEach(childNode => {
container.appendChild(transformDOMNode(childNode)); container.appendChild(transformDOMNode(childNode));
}); });
} }
@@ -3453,13 +3447,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) { if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) {
currentChapterIndex = activeCh; currentChapterIndex = activeCh;
if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; 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 (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; 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 // 2. Infinite scroll: auto-append next chapter in continuous mode or chapters mode with autoload
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) { if ((readerSettings.flowMode === 'continuous' || (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)) && !isAppendingChapter) {
const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight); const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight);
if (distanceToBottom < 450) { if (distanceToBottom < 700) {
const loadedArr = Array.from(loadedChaptersSet); const loadedArr = Array.from(loadedChaptersSet);
const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex; const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex;
if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) { if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) {
isAppendingChapter = true; isAppendingChapter = true;
appendNextChapter(highestCh + 1, false); appendNextChapter(highestCh + 1, false);
setTimeout(() => { isAppendingChapter = false; }, 300); setTimeout(() => { isAppendingChapter = false; }, 200);
} }
} }
} }
@@ -3832,6 +3819,18 @@ document.addEventListener('DOMContentLoaded', () => {
initReaderSettingsPanel(); 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) { if (readerToggleHighlightBtn) {
readerToggleHighlightBtn.addEventListener('click', () => { readerToggleHighlightBtn.addEventListener('click', () => {
isHighlightEnabled = !isHighlightEnabled; isHighlightEnabled = !isHighlightEnabled;