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 <tr>) wasn't the
whole story: display:flex directly on a <td> (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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-06 11:58:42 +02:00
parent cb76a2f237
commit 139cb6b85b
6 changed files with 105 additions and 20 deletions

View File

@ -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 `<td>` (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 `<div>`s and switched the table to `table-layout:fixed` with an explicit `<colgroup>` for good measure.
---
## [1.13.3] — 2026-07-06
### Fixed

View File

@ -1 +1 @@
1.13.3
1.13.4

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.13.3">
<meta name="app-version" content="1.13.4">
<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.13.3">
<link rel="stylesheet" href="/static/style.css?v=1.13.4">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.13.3"></script>
<script src="/static/loader.js?v=1.13.4"></script>
</body>
</html>

View File

@ -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 = `
<div class="ab-alias-popup-title">Also known as <b>${escHtml(name)}</b></div>
<input type="text" class="ab-alias-popup-inp" placeholder="e.g. Garthai, der Fremde" autocomplete="off">
<input type="text" class="ab-alias-popup-inp" placeholder="e.g. Garthai, der Fremde — or pick a recognized character below" autocomplete="off" list="${listId}">
<datalist id="${listId}">
${otherNames.map(([n, info]) => `<option value="${escHtml(n)}">${escHtml(n)} (${info.count} lines)</option>`).join('')}
</datalist>
${otherNames.length ? `<div class="ab-alias-popup-hint">Or merge with an already-found character:</div>
<div class="ab-alias-popup-list">
${otherNames.slice(0, 8).map(([n, info]) => `<button type="button" class="ab-alias-merge-opt" data-name="${escHtml(n)}">${escHtml(n)} <span class="ab-alias-merge-count">${info.count}</span></button>`).join('')}
</div>` : ''}
<div class="ab-alias-popup-actions">
<button type="button" class="btn-secondary btn-sm ab-alias-cancel">Cancel</button>
<button type="button" class="btn-primary btn-sm ab-alias-save">Add</button>
</div>`;
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 {

View File

@ -380,9 +380,9 @@ function _charsTableHtml(chars) {
+ '<td>' + (sh.line_count != null ? sh.line_count : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (voiceLang ? escHtml(voiceLang) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (pct != null ? '<div class="lib-char-align-bar" title="' + pct + '/100"><div class="lib-char-align-dot" style="left:' + pct + '%"></div></div>' : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td class="lib-chars-tbl-voice">' + (voiceId ? escHtml(voiceId) : '<span class="lib-chars-tbl-dash">Keine Stimme</span>')
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button><button class="lib-char-auto-voice btn-sm">Auto</button></td>'
+ '<td class="lib-chars-tbl-tags">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</td>'
+ '<td class="lib-chars-tbl-voice"><div class="lib-chars-tbl-voice-wrap">' + (voiceId ? escHtml(voiceId) : '<span class="lib-chars-tbl-dash">Keine Stimme</span>')
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button><button class="lib-char-auto-voice btn-sm">Auto</button></div></td>'
+ '<td class="lib-chars-tbl-tags"><div class="lib-chars-tbl-tags-wrap">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</div></td>'
+ '<td>' + promptCell('silly_tavern_prompt', 'SillyTavern') + '</td>'
+ '<td>' + promptCell('voice_design_prompt', 'TTS Voice') + '</td>'
+ '<td>' + promptCell('image_prompt', 'Bild') + '</td>'
@ -390,6 +390,15 @@ function _charsTableHtml(chars) {
+ '</tr>';
}).join('');
return '<div class="lib-chars-tbl-wrap"><table class="lib-chars-tbl">'
// 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.
+ '<colgroup><col style="width:32px"><col style="width:44px"><col style="width:160px">'
+ '<col style="width:36px"><col style="width:60px"><col style="width:80px"><col style="width:90px">'
+ '<col style="width:220px"><col style="width:200px"><col style="width:40px"><col style="width:40px">'
+ '<col style="width:40px"><col style="width:36px"></colgroup>'
+ '<thead><tr>'
+ '<th></th><th></th><th>Name</th><th title="Geschlecht">⚥</th><th title="Anzahl Zeilen">Zeilen</th><th>Sprache</th>'
+ '<th title="Moralische Gesinnung">Gut/Böse</th><th>Stimme</th><th>Tags</th>'

View File

@ -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 <td> 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 {