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:
parent
da3d442656
commit
f837ba1a4f
@ -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).
|
- **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.
|
- **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
|
## [1.20.29] — 2026-08-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
16
static/dist/main.min.js
vendored
16
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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.20.29">
|
<meta name="app-version" content="1.20.31">
|
||||||
<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.20.29">
|
<link rel="stylesheet" href="/static/style.css?v=1.20.31">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -378,7 +378,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.20.29"></script>
|
<script src="/static/loader.js?v=1.20.31"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1320,10 +1320,18 @@ function _abRecordColor(rec, name) {
|
|||||||
let _abShowQuotes = (() => { try { return localStorage.getItem('ab-show-quotes') !== '0'; } catch (_) { return true; } })();
|
let _abShowQuotes = (() => { try { return localStorage.getItem('ab-show-quotes') !== '0'; } catch (_) { return true; } })();
|
||||||
function _abDisplayText(s) {
|
function _abDisplayText(s) {
|
||||||
const t = String(s?.text || '');
|
const t = String(s?.text || '');
|
||||||
if (!_abShowQuotes || s?.type !== 'dialogue' || !t.trim()) return t;
|
if (s?.type !== 'dialogue' || !t.trim()) return t;
|
||||||
if (/^\s*[»„"']/.test(t)) return t; // already quoted
|
if (/^\s*[»„"']/.test(t)) return t; // already carries its own marks
|
||||||
return '»' + t.trim() + '«';
|
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) {
|
function audiobookToggleQuotes(on) {
|
||||||
_abShowQuotes = (on === undefined) ? !_abShowQuotes : !!on;
|
_abShowQuotes = (on === undefined) ? !_abShowQuotes : !!on;
|
||||||
try { localStorage.setItem('ab-show-quotes', _abShowQuotes ? '1' : '0'); } catch (_) {}
|
try { localStorage.setItem('ab-show-quotes', _abShowQuotes ? '1' : '0'); } catch (_) {}
|
||||||
@ -1331,9 +1339,11 @@ function audiobookToggleQuotes(on) {
|
|||||||
b.classList.toggle('is-on', _abShowQuotes);
|
b.classList.toggle('is-on', _abShowQuotes);
|
||||||
b.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks';
|
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;
|
return _abShowQuotes;
|
||||||
}
|
}
|
||||||
|
try { document.body.classList.toggle('ab-hide-quotes', !_abShowQuotes); } catch (_) {}
|
||||||
window.audiobookToggleQuotes = audiobookToggleQuotes;
|
window.audiobookToggleQuotes = audiobookToggleQuotes;
|
||||||
|
|
||||||
function highlightText(text, names) {
|
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 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>
|
||||||
</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>
|
<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>
|
||||||
<div class="ab-cv-side" id="ab-cv-side">
|
<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 spk = row.querySelector('.ab-cv-spk');
|
||||||
const txt = row.querySelector('.ab-cv-txt');
|
const txt = row.querySelector('.ab-cv-txt');
|
||||||
if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c;
|
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();
|
renderRoster();
|
||||||
const selected = _abBar.hidden ? null : _abBar.dataset.charName;
|
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>
|
<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>
|
||||||
<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-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,
|
// 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
|
// attaching 2 extra listeners per row on every redraw (merge/split rebuilds
|
||||||
// the whole feed) was measurably slower. Editing is wired once via event
|
// the whole feed) was measurably slower. Editing is wired once via event
|
||||||
@ -3033,7 +3043,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ta.replaceWith(txtEl);
|
ta.replaceWith(txtEl);
|
||||||
txtEl.innerHTML = highlightText(_abDisplayText(s));
|
txtEl.innerHTML = _abMarkQuotes(highlightText(_abDisplayText(s)));
|
||||||
};
|
};
|
||||||
ta.addEventListener('keydown', e => {
|
ta.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commit(true); }
|
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');
|
const row = document.createElement('div');
|
||||||
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
||||||
row.__seg = s;
|
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);
|
frag.appendChild(row);
|
||||||
}
|
}
|
||||||
if (rest.length) {
|
if (rest.length) {
|
||||||
@ -3882,7 +3892,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
||||||
row.__seg = s;
|
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);
|
f2.appendChild(row);
|
||||||
}
|
}
|
||||||
next.replaceWith(f2);
|
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' : ''}">
|
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-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'}>
|
<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('');
|
</div>`).join('');
|
||||||
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
||||||
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
|
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
|
||||||
|
|||||||
@ -7322,3 +7322,5 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
|
|
||||||
/* Quotation-mark visibility toggle in the casting view */
|
/* 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); }
|
.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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user