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:
parent
139cb6b85b
commit
ebd18bcc14
10
CHANGELOG.md
10
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
|
## [1.13.4] — 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">A–Z</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);
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user