From ebd18bcc145edc0b59851d90f01270bf73c44b22 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 6 Jul 2026 12:12:36 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 10 +++ VERSION | 2 +- static/index.html | 6 +- static/js/audiobook.js | 153 ++++++++++++++++++++++++++++++++++------- static/style.css | 11 +++ 5 files changed, 155 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9077250..a9ca53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Added diff --git a/VERSION b/VERSION index 80138e7..43ded90 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.4 +1.13.5 diff --git a/static/index.html b/static/index.html index 663f433..4f3f150 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 656679c..d2c312e 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1015,6 +1015,30 @@ function highlightText(text, names) { // Small dropdown anchored to the "Recast options" caret next to "View // Characters", once a book's already been cast before — offers the existing // 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 = `
+ +
${escHtml(message)}
+ ${withProgress ? '
' : ''} +
`; + 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) { document.querySelectorAll('.ab-recast-menu').forEach(el => el.remove()); const menu = document.createElement('div'); @@ -1145,6 +1169,13 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) { Characters found +
+ + +
${[38,62,45,28,54,35].map(w => `
`).join('')} @@ -1932,9 +1963,13 @@ STRIKTE FORMAT- UND TEXTREGELN: inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _doSearch(true); } }); }; + let _abRosterFilter = ''; + let _abRosterSort = 'count'; const renderRoster = () => { - const items = [...roster.entries()].filter(([, info]) => info.count > 0).sort((a, b) => b[1].count - a[1].count); - if (!items.length) { chars.innerHTML = 'reading…'; return; } + const q = _abRosterFilter.trim().toLowerCase(); + 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 = `${q ? 'No matches' : 'reading…'}`; return; } const prev = _abBar.hidden ? null : _abBar.dataset.charName; chars.innerHTML = items.map(([n, info]) => { 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 // (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 @@ -1996,25 +2048,45 @@ STRIKTE FORMAT- UND TEXTREGELN: const alias = (mergeName || inp.value.trim()); if (!alias) { _abCloseAliasPopup(); return; } const mergeTarget = otherByLower.get(alias.toLowerCase()); - try { - const book = window.readerState?.title || ''; - const rec = await clUpsert(book, { name, aliases: alias }); - if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; } - if (mergeTarget) { - // The typed/picked name is an EXISTING roster entry, not just a new - // alias string — actually reassign its segments to this character - // so the two split roster entries become one, not just a linked - // library alias that leaves the live cast still showing both. - const n = _abMergeCharacters(mergeTarget, name); + _abCloseAliasPopup(); + if (mergeTarget) { + // Merging rewrites every matching segment (fast) then has to rebuild + // the whole feed DOM — on a big book (thousands of rows, each running + // highlightText's regex pass) that redraw alone blocks the main + // thread long enough to trigger the browser's own "Page Unresponsive" + // dialog, not just look a bit slow. A spinner overlay by itself can't + // fix that — it would freeze right along with everything else, since + // it's all one synchronous JS turn. The redraw has to actually be + // 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'); - } 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(); 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-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. // "Darag" / "Schmied" for the same person) and the user picks the other // 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) { const active = _abActiveSegments(); - if (!active.arr.length) return 0; + if (!active.arr.length) return { changed: 0, arr: active.arr }; _abPushEditState(active.key, active.arr); let changed = 0; for (const s of active.arr) { @@ -2048,12 +2124,7 @@ STRIKTE FORMAT- UND TEXTREGELN: changed++; } } - if (changed) { - _abRecountRoster(active.arr); - _abRedrawSegments(active.arr); - _abPersistManualEdit(); - } - return changed; + return { changed, arr: active.arr }; } (async () => { @@ -2386,6 +2457,42 @@ STRIKTE FORMAT- UND TEXTREGELN: _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) => { if (!snap) return; const restored = _abCloneSegments(snap.segments); diff --git a/static/style.css b/static/style.css index b589c60..5809e62 100644 --- a/static/style.css +++ b/static/style.css @@ -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: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-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-empty { font-size: 12px; color: var(--subtext); font-style: italic; } /* Collapsed sidebar: shrink to just the avatar dots, hide name/count/labels.