Fix merge-freeze with chunked redraw, add sidebar search/sort (v1.13.5)

Merging two characters could block the main thread long enough to
trigger the browser's own "Page Unresponsive" dialog - the merge
itself is a fast array loop, but redrawing the whole feed afterward
(thousands of DOM rows, each running highlightText's regex pass) was
one long synchronous chunk. A plain spinner overlay can't fix that,
since it freezes right along with everything else in the same JS
turn. Split _abMergeCharacters into a fast relabel step plus a new
_abRedrawSegmentsChunked that rebuilds the feed across animation
frames, driving a real progress bar in the busy overlay instead of a
static "please wait".

Also added search + sort (line count / alphabetical) to the Casting
sidebar's character list, matching the Library's character list
controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-06 12:12:36 +02:00
parent 139cb6b85b
commit ebd18bcc14
5 changed files with 155 additions and 27 deletions

View File

@ -9,6 +9,16 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.13.5] — 2026-07-06
### Added
- **Search + sort in the Casting sidebar** ("Characters found") — filter by name and sort by line count or alphabetically, matching the same controls added to the Library's character list.
### Fixed
- **Merging characters could freeze the page long enough to trigger the browser's "Page Unresponsive" dialog** — the merge itself is a fast array loop, but redrawing the whole feed afterward (thousands of DOM rows, each running the highlight regex pass) was one long synchronous block. A spinner overlay alone couldn't fix this — it would freeze right along with everything else in the same JS turn. The redraw is now chunked across animation frames with a real, moving progress bar, so the browser stays responsive and the merge no longer looks like a crash on a large book.
---
## [1.13.4] — 2026-07-06 ## [1.13.4] — 2026-07-06
### Added ### Added

View File

@ -1 +1 @@
1.13.4 1.13.5

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.13.4"> <meta name="app-version" content="1.13.5">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.13.4"> <link rel="stylesheet" href="/static/style.css?v=1.13.5">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.13.4"></script> <script src="/static/loader.js?v=1.13.5"></script>
</body> </body>
</html> </html>

View File

@ -1015,6 +1015,30 @@ function highlightText(text, names) {
// Small dropdown anchored to the "Recast options" caret next to "View // Small dropdown anchored to the "Recast options" caret next to "View
// Characters", once a book's already been cast before — offers the existing // Characters", once a book's already been cast before — offers the existing
// full recast plus a targeted "recast just these characters" flow. // full recast plus a targeted "recast just these characters" flow.
// Lightweight "working on it" overlay for operations that block the main
// thread for a noticeable moment (e.g. rewriting + redrawing thousands of
// segments on a character merge) — without this, a long synchronous chunk
// of work just makes the page stop responding with no visible cause, which
// reads as a crash rather than "still working". Returns the overlay element;
// call .remove() on it when the work finishes.
function _abShowBusyOverlay(message, withProgress) {
const ov = document.createElement('div');
ov.className = 'audiobook-overlay ab-busy-overlay';
ov.innerHTML = `<div class="audiobook-box" style="text-align:center;">
<span class="mdi mdi-loading mdi-spin" style="font-size:28px; color:var(--accent);"></span>
<div class="audiobook-msg ab-busy-msg" style="margin:12px 0 0;">${escHtml(message)}</div>
${withProgress ? '<div class="reader-synth-track" style="margin-top:10px"><div class="reader-synth-fill ab-busy-fill" style="width:0%"></div></div>' : ''}
</div>`;
document.body.appendChild(ov);
ov.setProgress = (done, total) => {
const fill = ov.querySelector('.ab-busy-fill');
if (fill && total) fill.style.width = Math.round((done / total) * 100) + '%';
const msgEl = ov.querySelector('.ab-busy-msg');
if (msgEl) msgEl.textContent = `${message} (${done} / ${total})`;
};
return ov;
}
function _abOpenRecastCharsMenu(anchorEl, bookTitle, existingChars) { function _abOpenRecastCharsMenu(anchorEl, bookTitle, existingChars) {
document.querySelectorAll('.ab-recast-menu').forEach(el => el.remove()); document.querySelectorAll('.ab-recast-menu').forEach(el => el.remove());
const menu = document.createElement('div'); const menu = document.createElement('div');
@ -1145,6 +1169,13 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
<span class="ab-cv-side-title">Characters found</span> <span class="ab-cv-side-title">Characters found</span>
<button class="ab-cv-side-collapse" id="ab-cv-side-collapse" type="button" title="Collapse to avatars"><span class="mdi mdi-chevron-left"></span></button> <button class="ab-cv-side-collapse" id="ab-cv-side-collapse" type="button" title="Collapse to avatars"><span class="mdi mdi-chevron-left"></span></button>
</div> </div>
<div class="ab-cv-side-tools">
<input type="text" class="ab-cv-side-search" id="ab-cv-side-search" placeholder="Search characters…" autocomplete="off">
<select class="ab-cv-side-sort" id="ab-cv-side-sort" title="Sort">
<option value="count">Lines</option>
<option value="alpha">AZ</option>
</select>
</div>
<div class="ab-cv-chars" id="ab-cv-chars"> <div class="ab-cv-chars" id="ab-cv-chars">
<div class="ab-skel-chars"> <div class="ab-skel-chars">
${[38,62,45,28,54,35].map(w => `<div class="ab-skel-char-row"><div class="ab-skel-dot"></div><div class="ab-skel-line" style="width:${w}%"></div><div class="ab-skel-num"></div></div>`).join('')} ${[38,62,45,28,54,35].map(w => `<div class="ab-skel-char-row"><div class="ab-skel-dot"></div><div class="ab-skel-line" style="width:${w}%"></div><div class="ab-skel-num"></div></div>`).join('')}
@ -1932,9 +1963,13 @@ STRIKTE FORMAT- UND TEXTREGELN:
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _doSearch(true); } }); inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _doSearch(true); } });
}; };
let _abRosterFilter = '';
let _abRosterSort = 'count';
const renderRoster = () => { const renderRoster = () => {
const items = [...roster.entries()].filter(([, info]) => info.count > 0).sort((a, b) => b[1].count - a[1].count); const q = _abRosterFilter.trim().toLowerCase();
if (!items.length) { chars.innerHTML = '<span class="ab-cv-empty">reading…</span>'; return; } let items = [...roster.entries()].filter(([n, info]) => info.count > 0 && (!q || n.toLowerCase().includes(q)));
items.sort(_abRosterSort === 'alpha' ? (a, b) => a[0].localeCompare(b[0]) : (a, b) => b[1].count - a[1].count);
if (!items.length) { chars.innerHTML = `<span class="ab-cv-empty">${q ? 'No matches' : 'reading…'}</span>`; return; }
const prev = _abBar.hidden ? null : _abBar.dataset.charName; const prev = _abBar.hidden ? null : _abBar.dataset.charName;
chars.innerHTML = items.map(([n, info]) => { chars.innerHTML = items.map(([n, info]) => {
const color = info.color || colorFor(n); const color = info.color || colorFor(n);
@ -1953,6 +1988,23 @@ STRIKTE FORMAT- UND TEXTREGELN:
}); });
}; };
(() => {
const searchInp = panel.querySelector('#ab-cv-side-search');
const sortSel = panel.querySelector('#ab-cv-side-sort');
try { _abRosterSort = localStorage.getItem('ttsvc_ab_side_sort') || 'count'; } catch (_) {}
if (sortSel) sortSel.value = _abRosterSort;
let _t = null;
searchInp?.addEventListener('input', () => {
clearTimeout(_t);
_t = setTimeout(() => { _abRosterFilter = searchInp.value; renderRoster(); }, 120);
});
sortSel?.addEventListener('change', () => {
_abRosterSort = sortSel.value;
try { localStorage.setItem('ttsvc_ab_side_sort', _abRosterSort); } catch (_) {}
renderRoster();
});
})();
// Quick "also known as" shortcut right in the sidebar, so adding an alias // Quick "also known as" shortcut right in the sidebar, so adding an alias
// (e.g. "Garthai" for "Sharraz Garthai", or "Ork" for a role-only speaker) // (e.g. "Garthai" for "Sharraz Garthai", or "Ork" for a role-only speaker)
// doesn't require leaving the casting screen for the full Character Library // doesn't require leaving the casting screen for the full Character Library
@ -1996,25 +2048,45 @@ STRIKTE FORMAT- UND TEXTREGELN:
const alias = (mergeName || inp.value.trim()); const alias = (mergeName || inp.value.trim());
if (!alias) { _abCloseAliasPopup(); return; } if (!alias) { _abCloseAliasPopup(); return; }
const mergeTarget = otherByLower.get(alias.toLowerCase()); const mergeTarget = otherByLower.get(alias.toLowerCase());
try { _abCloseAliasPopup();
const book = window.readerState?.title || ''; if (mergeTarget) {
const rec = await clUpsert(book, { name, aliases: alias }); // Merging rewrites every matching segment (fast) then has to rebuild
if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; } // the whole feed DOM — on a big book (thousands of rows, each running
if (mergeTarget) { // highlightText's regex pass) that redraw alone blocks the main
// The typed/picked name is an EXISTING roster entry, not just a new // thread long enough to trigger the browser's own "Page Unresponsive"
// alias string — actually reassign its segments to this character // dialog, not just look a bit slow. A spinner overlay by itself can't
// so the two split roster entries become one, not just a linked // fix that — it would freeze right along with everything else, since
// library alias that leaves the live cast still showing both. // it's all one synchronous JS turn. The redraw has to actually be
const n = _abMergeCharacters(mergeTarget, name); // chunked across animation frames so the browser can keep painting,
// which is also what makes a real (not fake) progress bar possible.
const busy = _abShowBusyOverlay(`Merging "${mergeTarget}" into ${name}`, true);
await new Promise(r => requestAnimationFrame(r));
try {
const book = window.readerState?.title || '';
const rec = await clUpsert(book, { name, aliases: alias });
if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; }
const { changed: n, arr } = _abMergeCharacters(mergeTarget, name);
if (n) {
await _abRedrawSegmentsChunked(arr, (done, total) => busy.setProgress(done, total));
_abPersistManualEdit();
}
toast(n ? `Merged "${mergeTarget}" into ${name} (${n} line${n !== 1 ? 's' : ''})` : `"${alias}" added as an alias for ${name}`, 'success'); toast(n ? `Merged "${mergeTarget}" into ${name} (${n} line${n !== 1 ? 's' : ''})` : `"${alias}" added as an alias for ${name}`, 'success');
} else { } catch (err) {
toast('Could not merge: ' + (err.message || err), 'error');
} finally {
busy.remove();
}
} else {
try {
const book = window.readerState?.title || '';
const rec = await clUpsert(book, { name, aliases: alias });
if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; }
renderRoster(); renderRoster();
toast(`"${alias}" added as an alias for ${name}`, 'success'); toast(`"${alias}" added as an alias for ${name}`, 'success');
} catch (err) {
toast('Could not save alias: ' + (err.message || err), 'error');
} }
} catch (err) {
toast('Could not save alias: ' + (err.message || err), 'error');
} }
_abCloseAliasPopup();
}; };
el.querySelector('.ab-alias-save').addEventListener('click', () => doSave()); el.querySelector('.ab-alias-save').addEventListener('click', () => doSave());
el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup()); el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup());
@ -2037,9 +2109,13 @@ STRIKTE FORMAT- UND TEXTREGELN:
// — used when the LLM split one character into two roster entries (e.g. // — used when the LLM split one character into two roster entries (e.g.
// "Darag" / "Schmied" for the same person) and the user picks the other // "Darag" / "Schmied" for the same person) and the user picks the other
// entry from the alias popup instead of typing a plain-text alias. // entry from the alias popup instead of typing a plain-text alias.
// Only relabels segments (fast, plain array loop) — the caller is
// responsible for redrawing the feed afterward, since on a big book that
// part is the slow one and needs to run through the chunked path with
// visible progress instead of blocking the main thread outright.
function _abMergeCharacters(fromName, intoName) { function _abMergeCharacters(fromName, intoName) {
const active = _abActiveSegments(); const active = _abActiveSegments();
if (!active.arr.length) return 0; if (!active.arr.length) return { changed: 0, arr: active.arr };
_abPushEditState(active.key, active.arr); _abPushEditState(active.key, active.arr);
let changed = 0; let changed = 0;
for (const s of active.arr) { for (const s of active.arr) {
@ -2048,12 +2124,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
changed++; changed++;
} }
} }
if (changed) { return { changed, arr: active.arr };
_abRecountRoster(active.arr);
_abRedrawSegments(active.arr);
_abPersistManualEdit();
}
return changed;
} }
(async () => { (async () => {
@ -2386,6 +2457,42 @@ STRIKTE FORMAT- UND TEXTREGELN:
_abSelectChar(selectedChar); _abSelectChar(selectedChar);
} }
}; };
// Same as _abRedrawSegments, but builds the feed in batches across several
// animation frames instead of one long synchronous loop — a full redraw on
// a big book (thousands of rows, each running highlightText's regex pass)
// blocks the main thread long enough to trigger the browser's own "Page
// Unresponsive" warning. Used by operations that redraw the WHOLE feed at
// once (character merge); the normal single-segment edits stay on the
// plain synchronous path since those are cheap regardless.
const _abRedrawSegmentsChunked = (segs, onProgress, batchSize = 150) => new Promise(resolve => {
const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : '';
feed.innerHTML = '';
_abCurPage = null;
const list = segs || [];
let i = 0, lastPage = null;
const step = () => {
const end = Math.min(i + batchSize, list.length);
for (; i < end; i++) {
const s = list[i];
if (s.page != null && s.page !== lastPage) {
_abNewPage('Page ' + s.page, s.page);
lastPage = s.page;
}
_abPage().appendChild(_abRowFromSegment(s));
}
if (onProgress) onProgress(i, list.length);
if (i < list.length) {
requestAnimationFrame(step);
} else {
_abRecountRoster(list);
_abClearHL();
_abUpdatePageNav();
if (selectedChar) { _abBar.hidden = true; _abSelectChar(selectedChar); }
resolve();
}
};
requestAnimationFrame(step);
});
const _abRestoreEditState = (snap) => { const _abRestoreEditState = (snap) => {
if (!snap) return; if (!snap) return;
const restored = _abCloneSegments(snap.segments); const restored = _abCloneSegments(snap.segments);

View File

@ -5424,6 +5424,17 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.ab-cv-side-collapse { border: none; background: none; color: var(--subtext); cursor: pointer; padding: 2px; border-radius: 4px; line-height: 1; flex-shrink: 0; } .ab-cv-side-collapse { border: none; background: none; color: var(--subtext); cursor: pointer; padding: 2px; border-radius: 4px; line-height: 1; flex-shrink: 0; }
.ab-cv-side-collapse:hover { background: var(--surface); color: var(--text); } .ab-cv-side-collapse:hover { background: var(--surface); color: var(--text); }
.ab-cv-side-done { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); flex: 0 0 auto; } .ab-cv-side-done { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); flex: 0 0 auto; }
.ab-cv-side-tools { display: flex; gap: 4px; margin-bottom: 6px; flex: 0 0 auto; }
.ab-cv-side-search {
flex: 1; min-width: 0; padding: 4px 7px; font-size: 11.5px; border: 1px solid var(--border);
border-radius: 5px; background: var(--surface); color: var(--text); outline: none;
}
.ab-cv-side-search:focus { border-color: var(--accent); }
.ab-cv-side-sort {
padding: 4px 5px; font-size: 11px; border: 1px solid var(--border); border-radius: 5px;
background: var(--surface); color: var(--text); cursor: pointer; flex-shrink: 0;
}
.ab-cv-side.is-collapsed .ab-cv-side-tools { display: none; }
.ab-cv-chars { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; padding-right: 2px; } .ab-cv-chars { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; padding-right: 2px; }
.ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; } .ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
/* Collapsed sidebar: shrink to just the avatar dots, hide name/count/labels. /* Collapsed sidebar: shrink to just the avatar dots, hide name/count/labels.