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:
1 parent
46d6973365
commit
c4a6edee37
3 files changed
+70
-38
No files matched your search
+41
-8
@@ -513,15 +513,17 @@ CLI_PROMPT_RE = re.compile(
|
||||
|
||||
def extract_outline_entries(outline, reader) -> List[Tuple[str, int]]:
|
||||
"""
|
||||
Recursively extracts bookmarks / outline entries from PDF.
|
||||
Returns a list of (title, 0-indexed page_number).
|
||||
Recursively extracts bookmarks / outline entries from PDF respecting hierarchy depth.
|
||||
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:
|
||||
if isinstance(item, list):
|
||||
_traverse(item)
|
||||
_traverse(item, depth + 1)
|
||||
else:
|
||||
try:
|
||||
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:
|
||||
clean_title = str(title).strip()
|
||||
if clean_title:
|
||||
entries.append((clean_title, int(page_num)))
|
||||
raw_entries.append((clean_title, int(page_num), depth))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if outline:
|
||||
_traverse(outline)
|
||||
return entries
|
||||
_traverse(outline, 0)
|
||||
|
||||
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]:
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<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>
|
||||
</html>
|
||||
|
||||
+28
-29
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user