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
+69
-37
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]]:
|
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]:
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user