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 {