Toggle quotation marks with CSS instead of rewriting text (v1.20.31)

CSS cannot target a character inside a text node, so the marks are now wrapped
in their own .ab-q element at render time and hidden with a single class on
body. Toggling is instant regardless of segment count, and the stored text is
never modified.

Also fixes the toggle looking inert on a freshly cast book: it previously only
added missing marks and never hid the ones the book already carried. Button
moved bottom-right as a larger labelled pill, clear of the search controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-08-12 13:14:35 +02:00
parent da3d442656
commit f837ba1a4f
6 changed files with 39 additions and 22 deletions

View File

@ -22,6 +22,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Emotion instructions are now always written in English**, even for non-English voices (the spoken text and the native-accent clause stay in the book's own language). Confirmed by controlled A/B testing — same line, same voice, only the instruct language varying — that Qwen3-TTS follows English emotion instructions far more reliably: German instructs produced barely-differentiated output, while English instructs yield a clean, correctly-ordered prosodic gradient (whisper 128 Hz → sad 142 → neutral 179 → scared 203 → happy 225 → angry 269 Hz), with sensible duration changes too (sad slowest, scared fastest).
- **Fish-Speech generation parameters (`temperature` / `top_p` / `repetition_penalty`) were never forwarded.** Every Fish-Speech line synthesized at the server's fixed defaults, ignoring the app's per-backend stability settings — the only backend not routed through the shared `_apply_tts_extra_params` helper.
## [1.20.31] — 2026-08-12
### Changed
- **The quotation-mark toggle is now pure CSS.** Guillemets are wrapped in their own `.ab-q` element at render time, so switching them off is a single class on `<body>` rather than rewriting every line's text and redrawing the whole feed — instant even with a couple of thousand segments, and the stored text is never touched. Also fixes the toggle appearing to do nothing on a freshly cast book: it previously only *added* missing marks and never hid the ones a book already had. The button moved to the bottom-right as a larger labelled pill, clear of the search controls it was overlapping.
## [1.20.29] — 2026-08-12
### Added

View File

@ -1 +1 @@
1.20.29
1.20.31

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1320,10 +1320,18 @@ function _abRecordColor(rec, name) {
let _abShowQuotes = (() => { try { return localStorage.getItem('ab-show-quotes') !== '0'; } catch (_) { return true; } })();
function _abDisplayText(s) {
const t = String(s?.text || '');
if (!_abShowQuotes || s?.type !== 'dialogue' || !t.trim()) return t;
if (/^\s*[»„"']/.test(t)) return t; // already quoted
if (s?.type !== 'dialogue' || !t.trim()) return t;
if (/^\s*[»„"']/.test(t)) return t; // already carries its own marks
return '»' + t.trim() + '«';
}
// Wrap guillemets in their own element AFTER escaping/highlighting, so hiding
// them is a pure CSS class flip on the container rather than a text rewrite and
// a full redraw of every row — CSS cannot target a character inside a text node,
// only an element. The marks stay in the DOM (and in the stored text, which is
// never touched); they are merely display:none when the toggle is off.
function _abMarkQuotes(html) {
return String(html || '').replace(/([»«„“”])/g, '<span class="ab-q">$1</span>');
}
function audiobookToggleQuotes(on) {
_abShowQuotes = (on === undefined) ? !_abShowQuotes : !!on;
try { localStorage.setItem('ab-show-quotes', _abShowQuotes ? '1' : '0'); } catch (_) {}
@ -1331,9 +1339,11 @@ function audiobookToggleQuotes(on) {
b.classList.toggle('is-on', _abShowQuotes);
b.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks';
});
if (Array.isArray(_audiobook.segments) && _audiobook.segments.length) _abRedrawSegmentsChunked(_audiobook.segments);
// One class on the root — instant, and cheap even with a couple of thousand rows.
document.body.classList.toggle('ab-hide-quotes', !_abShowQuotes);
return _abShowQuotes;
}
try { document.body.classList.toggle('ab-hide-quotes', !_abShowQuotes); } catch (_) {}
window.audiobookToggleQuotes = audiobookToggleQuotes;
function highlightText(text, names) {
@ -1627,7 +1637,7 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:64%"></div></div>
</div>
</div>
<button class="btn-secondary btn-sm ab-cv-quote-toggle" id="ab-cv-quotes" type="button" title="Show quotation marks" style="position:absolute; top:8px; right:12px; z-index:3"><span class="mdi mdi-format-quote-close"></span></button>
<button class="btn-secondary ab-cv-quote-toggle" id="ab-cv-quotes" type="button" title="Show quotation marks" style="position:absolute; bottom:12px; right:16px; z-index:5; padding:8px 14px; border-radius:20px; box-shadow:0 2px 8px rgba(0,0,0,.18)"><span class="mdi mdi-format-quote-close"></span> <span class="ab-cv-quotes-label">Quotes</span></button>
<button class="ab-cv-jump-btn" id="ab-cv-jump-btn" hidden title="Jump to latest"><span class="mdi mdi-chevron-double-down"></span> Live</button>
</div>
<div class="ab-cv-side" id="ab-cv-side">
@ -2253,7 +2263,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const spk = row.querySelector('.ab-cv-spk');
const txt = row.querySelector('.ab-cv-txt');
if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c;
if (txt) txt.innerHTML = highlightText(_abDisplayText(seg));
if (txt) txt.innerHTML = _abMarkQuotes(highlightText(_abDisplayText(seg)));
});
renderRoster();
const selected = _abBar.hidden ? null : _abBar.dataset.charName;
@ -2951,7 +2961,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
<button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button>
</span>
<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span>
<span class="ab-cv-txt">${highlightText(_abDisplayText(s))}</span>`;
<span class="ab-cv-txt">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</span>`;
// No per-row listeners here on purpose — with 1000+ segments in a big book,
// attaching 2 extra listeners per row on every redraw (merge/split rebuilds
// the whole feed) was measurably slower. Editing is wired once via event
@ -3033,7 +3043,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
}
}
ta.replaceWith(txtEl);
txtEl.innerHTML = highlightText(_abDisplayText(s));
txtEl.innerHTML = _abMarkQuotes(highlightText(_abDisplayText(s)));
};
ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commit(true); }
@ -3863,7 +3873,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const row = document.createElement('div');
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
row.__seg = s;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(_abDisplayText(s))}</span>`;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</span>`;
frag.appendChild(row);
}
if (rest.length) {
@ -3882,7 +3892,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const row = document.createElement('div');
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
row.__seg = s;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(_abDisplayText(s))}</span>`;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</span>`;
f2.appendChild(row);
}
next.replaceWith(f2);
@ -5391,7 +5401,7 @@ function audiobookShowPreview() {
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
<div class="audiobook-seg-text">${highlightText(_abDisplayText(s))}</div>
<div class="audiobook-seg-text">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</div>
</div>`).join('');
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });

View File

@ -7322,3 +7322,5 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* Quotation-mark visibility toggle in the casting view */
.ab-cv-quote-toggle.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* Quotation marks are wrapped in .ab-q so the toggle is a pure CSS flip */
body.ab-hide-quotes .ab-q { display: none; }