From 139cb6b85b92d20b64a74d3739b8e6b990f79e48 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 6 Jul 2026 11:58:42 +0200 Subject: [PATCH] Fix table-view column misalignment for real, add character-merge via alias popup (v1.13.4) The earlier table-view fix (display:table-row on ) wasn't the whole story: display:flex directly on a (Stimme, Tags columns) also broke its table-cell participation in Chromium, rendering that cell stacked at the PREVIOUS column's x-position regardless of table-layout mode - confirmed via direct DOM/rect inspection, not guesswork. Moved flex layout to inner wrapper divs and switched to table-layout:fixed with an explicit colgroup so column widths are never re-negotiated by content again. Also added actual character merging to the "also known as" alias popup: picking an existing roster entry (e.g. "Schmied" from Darag's popup, when the LLM split one person into two roster names) reassigns every one of its segments to the character you opened the popup from, with undo support - not just a linked library alias that left the live cast still showing both as separate people. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 +++++ VERSION | 2 +- static/index.html | 6 +-- static/js/audiobook.js | 70 ++++++++++++++++++++++++++++----- static/js/library-characters.js | 15 +++++-- static/style.css | 22 +++++++++-- 6 files changed, 105 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 914d14d..9077250 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.4] — 2026-07-06 + +### Added +- **Merge two roster entries from the "also known as" popup** — when the LLM splits one character into two roster entries (e.g. "Darag" and "Schmied" for the same person), the alias popup now lists other already-recognized characters as pick-to-merge options, not just a free-text field. Picking one actually reassigns every one of its segments to the character you opened the popup from (not just a linked library alias that leaves the live cast still showing both), with full undo support. + +### Fixed +- **Character table view still misaligned after the earlier fix** — `display:flex` directly on a `` (Stimme, Tags columns) broke its table-cell layout participation entirely in Chromium, rendering the cell stacked at the previous column's position regardless of table-layout mode. Moved the flex layout to inner wrapper `
`s and switched the table to `table-layout:fixed` with an explicit `` for good measure. + +--- + ## [1.13.3] — 2026-07-06 ### Fixed diff --git a/VERSION b/VERSION index 01b7568..80138e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.3 +1.13.4 diff --git a/static/index.html b/static/index.html index c204bf8..663f433 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 f6065cf..656679c 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1962,38 +1962,67 @@ STRIKTE FORMAT- UND TEXTREGELN: function _abCloseAliasPopup() { if (_abAliasPopup) { _abAliasPopup.remove(); _abAliasPopup = null; } } function _abOpenAliasPopup(name, anchorEl) { _abCloseAliasPopup(); + // Other already-recognized roster names, offered as pick-to-merge targets + // (e.g. "Darag" also known as "Schmied" — the LLM split one character + // into two roster entries) instead of only accepting a free-text alias. + const otherNames = [...roster.entries()] + .filter(([n, info]) => info.count > 0 && n.toLowerCase() !== name.toLowerCase() && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n)) + .sort((a, b) => b[1].count - a[1].count); + const listId = 'ab-alias-roster-list'; const el = document.createElement('div'); el.className = 'ab-alias-popup'; el.innerHTML = `
Also known as — ${escHtml(name)}
- + + + ${otherNames.map(([n, info]) => ``).join('')} + + ${otherNames.length ? `
Or merge with an already-found character:
+
+ ${otherNames.slice(0, 8).map(([n, info]) => ``).join('')} +
` : ''}
`; document.body.appendChild(el); const rect = anchorEl.getBoundingClientRect(); - el.style.left = Math.min(rect.left, window.innerWidth - 260) + 'px'; - el.style.top = Math.min(rect.bottom + 4, window.innerHeight - 120) + 'px'; + el.style.left = Math.min(rect.left, window.innerWidth - 280) + 'px'; + el.style.top = Math.min(rect.bottom + 4, window.innerHeight - 340) + 'px'; const inp = el.querySelector('.ab-alias-popup-inp'); setTimeout(() => inp.focus(), 30); - const save = async () => { - const alias = inp.value.trim(); + const otherByLower = new Map(otherNames.map(([n]) => [n.toLowerCase(), n])); + const doSave = async (mergeName) => { + 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); renderRoster(); if (_hlCache) _hlCache.ver = -1; } - toast(`"${alias}" added as an alias for ${name}`, 'success'); + 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); + toast(n ? `Merged "${mergeTarget}" into ${name} (${n} line${n !== 1 ? 's' : ''})` : `"${alias}" added as an alias for ${name}`, 'success'); + } else { + renderRoster(); + toast(`"${alias}" added as an alias for ${name}`, 'success'); + } } catch (err) { toast('Could not save alias: ' + (err.message || err), 'error'); } _abCloseAliasPopup(); }; - el.querySelector('.ab-alias-save').addEventListener('click', save); + el.querySelector('.ab-alias-save').addEventListener('click', () => doSave()); el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup()); + el.querySelectorAll('.ab-alias-merge-opt').forEach(btn => { + btn.addEventListener('click', () => doSave(btn.dataset.name)); + }); inp.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.preventDefault(); save(); } + if (e.key === 'Enter') { e.preventDefault(); doSave(); } else if (e.key === 'Escape') { e.preventDefault(); _abCloseAliasPopup(); } }); setTimeout(() => { @@ -2004,6 +2033,29 @@ STRIKTE FORMAT- UND TEXTREGELN: _abAliasPopup = el; } + // Reassign every segment currently attributed to fromName over to intoName + // — 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. + function _abMergeCharacters(fromName, intoName) { + const active = _abActiveSegments(); + if (!active.arr.length) return 0; + _abPushEditState(active.key, active.arr); + let changed = 0; + for (const s of active.arr) { + if (s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() === fromName.toLowerCase()) { + s.speaker = intoName; + changed++; + } + } + if (changed) { + _abRecountRoster(active.arr); + _abRedrawSegments(active.arr); + _abPersistManualEdit(); + } + return changed; + } + (async () => { const title = window.readerState?.title || ''; try { diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 6240379..93d27bc 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -380,9 +380,9 @@ function _charsTableHtml(chars) { + '' + (sh.line_count != null ? sh.line_count : '') + '' + '' + (voiceLang ? escHtml(voiceLang) : '') + '' + '' + (pct != null ? '
' : '') + '' - + '' + (voiceId ? escHtml(voiceId) : 'Keine Stimme') - + '' - + '' + tagList.map(function (t) { return '' + escHtml(t) + ''; }).join('') + '' + + '
' + (voiceId ? escHtml(voiceId) : 'Keine Stimme') + + '
' + + '
' + tagList.map(function (t) { return '' + escHtml(t) + ''; }).join('') + '
' + '' + promptCell('silly_tavern_prompt', 'SillyTavern') + '' + '' + promptCell('voice_design_prompt', 'TTS Voice') + '' + '' + promptCell('image_prompt', 'Bild') + '' @@ -390,6 +390,15 @@ function _charsTableHtml(chars) { + ''; }).join(''); return '
' + // table-layout:auto put a max-width'd wrapping cell (Tags) at the wrong + // physical position — its own header stayed put but the cell rendered + // stacked under the previous column instead, a Chromium auto-layout + // quirk from mixing content-based and max-width-constrained columns in + // the same row. Fixed explicit widths sidestep the whole class of bug. + + '' + + '' + + '' + + '' + '' + '' + '' diff --git a/static/style.css b/static/style.css index 113d107..b589c60 100644 --- a/static/style.css +++ b/static/style.css @@ -569,7 +569,8 @@ audio { width: 100%; } /* Table view — dense alternative to the card grid for scanning a large cast */ .lib-chars-tbl-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:8px; } -.lib-chars-tbl { width:100%; border-collapse:collapse; font-size:12.5px; white-space:nowrap; } +.lib-chars-tbl { width:100%; min-width:1100px; table-layout:fixed; border-collapse:collapse; font-size:12.5px; white-space:nowrap; } +.lib-chars-tbl td { overflow: hidden; text-overflow: ellipsis; } .lib-chars-tbl thead th { text-align:left; padding:8px 10px; font-size:10.5px; font-weight:800; text-transform:uppercase; letter-spacing:.04em; color:var(--subtext); border-bottom:1px solid var(--border); background:var(--panel); @@ -591,9 +592,13 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba .lib-chars-tbl-row .lib-char-export { position:static; opacity:1; width:auto; height:auto; border:0; background:none; color:var(--subtext); padding:2px; } .lib-chars-tbl-row .lib-char-export:hover { color:var(--accent); background:none; border:0; } .lib-chars-tbl-name { font-weight:600; white-space:normal; min-width:120px; } -.lib-chars-tbl-voice { display:flex; align-items:center; gap:6px; } +.lib-chars-tbl-voice-wrap { display:flex; align-items:center; gap:6px; } .lib-chars-tbl-voice button { padding:2px 7px; font-size:11px; } -.lib-chars-tbl-tags { white-space:normal; display:flex; flex-wrap:wrap; gap:3px; max-width:220px; } +/* display:flex directly on a
NameZeilenSpracheGut/BöseStimmeTags breaks its table-cell layout participation + (Chrome renders it detached from the column grid — width/position stop + matching its own header entirely). Flex only the inner wrapper instead. */ +.lib-chars-tbl-tags { white-space:normal; max-width:220px; } +.lib-chars-tbl-tags-wrap { display:flex; flex-wrap:wrap; gap:3px; } .lib-chars-tbl-dash { opacity:.4; } .lib-chars-tbl-check { display:inline-flex; } .lib-chars-tbl-check.is-yes { color:#4caf50; } @@ -5506,7 +5511,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .ab-cv-side.is-collapsed .ab-char-alias-btn { display: none !important; } .ab-alias-popup { - position: fixed; z-index: 2002; width: 240px; + position: fixed; z-index: 2002; width: 260px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 25px rgba(0,0,0,.35); padding: 10px; display: flex; flex-direction: column; gap: 8px; } @@ -5517,6 +5522,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami border-radius: 6px; background: var(--bg, var(--surface)); color: var(--text); outline: none; } .ab-alias-popup-inp:focus { border-color: var(--accent); } +.ab-alias-popup-hint { font-size: 10.5px; color: var(--subtext); text-transform: uppercase; letter-spacing: .03em; font-weight: 700; margin-top: -2px; } +.ab-alias-popup-list { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; } +.ab-alias-merge-opt { + display: flex; justify-content: space-between; align-items: center; gap: 8px; + padding: 5px 7px; font-size: 12px; border: 1px solid transparent; border-radius: 5px; + background: none; color: var(--text); cursor: pointer; text-align: left; +} +.ab-alias-merge-opt:hover { background: var(--panel); border-color: var(--border); } +.ab-alias-merge-count { font-size: 10.5px; color: var(--subtext); font-weight: 700; } .ab-alias-popup-actions { display: flex; justify-content: flex-end; gap: 6px; } /* Character detail panel inside the casting feed area */ .ab-char-detail-panel {