Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_script_cards.js
T
DemonGatanjieuandClaude Opus 5.5 fe60ede301 Voice-over cards: pick with a click, edit with a double-click; each line has its character
Every line is read by its own character: pasted and added lines go to
the one picked in the list on the left, the node's character is the
first line's, and the "Who speaks" bar is gone. Cards (ui_script_cards.js)
show the text as text: a click picks, a double-click or the pencil edits
it in place (Enter keeps, Esc puts back), and the name on a card opens a
menu of characters (all picked lines when it is one of them). Language
and speed are no longer offered to keep when several characters read;
the settings link names the listened line's character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:20:01 +08:00

214 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t } from './interface_settings.js';
import { anomalousAlert } from './ui_dialog.js';
import { splitSegmentAt, usableEmotions } from './audio_script.js';
import { speakerColor } from './ui_script_cast.js';
/**
* The Script Director's line cards. A click picks a card (Ctrl / ⌘ adds, Shift a run; the
* director keeps what is picked), a double-click or ✎ edits its text in place (Enter or a click
* elsewhere keeps it, Esc puts it back), the name on a card opens a menu of characters to read
* it (all the picked cards when it is one of them), and its emotion chips are its character's.
* The director owns the lines; a card only calls back (`ctx`, see renderLineCard).
*/
const SPEAKER_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/></svg>';
let openMenu = null;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick, title) {
const btn = el('button', className, label);
btn.type = 'button';
if (title) {
btn.title = title;
btn.setAttribute('aria-label', title);
}
btn.onclick = onClick;
return btn;
}
export const emotionLabel = emotion => (emotion === 'main' ? t('scriptDirectorMainEmotion') : emotion);
function closeSpeakerMenu() {
openMenu?.();
openMenu = null;
}
/** The characters to read a line, under its name chip; `choose(key)`. */
function openSpeakerMenu(anchor, groups, current, choose) {
closeSpeakerMenu();
const menu = el('div', 'anomalous-sd-speaker-menu');
menu.setAttribute('role', 'menu'); // the browser's Esc leaves an open menu alone
for (const group of groups) {
const item = button(`anomalous-sd-speaker-item${group.group === current ? ' is-current' : ''}`, '', () => {
closeSpeakerMenu();
choose(group.group);
});
item.setAttribute('role', 'menuitem');
const dot = el('span', 'anomalous-sd-speaker-dot');
dot.style.background = speakerColor(group.group, groups);
item.append(dot, el('span', '', group.character));
menu.append(item);
}
const rect = anchor.getBoundingClientRect();
menu.style.left = `${Math.round(rect.left)}px`;
menu.style.top = `${Math.round(rect.bottom + 4)}px`;
document.body.append(menu);
const away = (e) => { if (!menu.contains(e.target) && e.target !== anchor) closeSpeakerMenu(); };
const escape = (e) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
closeSpeakerMenu();
};
window.addEventListener('pointerdown', away, true);
window.addEventListener('keydown', escape, true);
openMenu = () => {
menu.remove();
window.removeEventListener('pointerdown', away, true);
window.removeEventListener('keydown', escape, true);
};
}
/** The text in place: a box to type in while editing, plain text otherwise. */
function lineText(line, index, ctx) {
if (ctx.editing !== index) {
const shown = el('div', `anomalous-sd-line-words${line.text.trim() ? '' : ' is-empty'}`, line.text.trim() || t('scriptDirectorEmptyLine'));
shown.title = t('scriptDirectorEditHint');
shown.addEventListener('dblclick', () => ctx.onEdit(index));
return { node: shown };
}
const box = el('textarea', 'anomalous-sd-line-text');
box.rows = 1;
box.value = line.text;
const before = line.text;
box.oninput = () => ctx.onText(index, box.value);
box.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
box.blur();
} else if (e.key === 'Escape') {
e.stopPropagation();
ctx.onText(index, before);
box.value = before;
box.blur();
}
});
requestAnimationFrame(() => {
box.focus();
box.setSelectionRange(box.value.length, box.value.length);
});
return { node: box, box };
}
/**
* One card. `ctx`: `groups`, `speakerOf(line)`, `picked` (Set), `listenIndex`, `editing` (index
* or null), `showSpeakers`, `showChips`, `count`, `canListen`, `canRetake`, and the calls back:
* `onPick(index, event)`, `onEdit(index | null)`, `onText(index, text)`, `onSplit(index, a, b)`,
* `onMerge(index)`, `onDelete(index)`, `onSpeaker(index, key)`, `onEmotion()`, `onListen(index)`,
* `onRetake(index)`, `onPreview(line, slices, button)`, `isPreviewing(button)`.
*/
export function renderLineCard(line, index, ctx) {
const speaker = ctx.speakerOf(line);
const emotions = usableEmotions(speaker);
const slices = new Map((speaker?.slices || []).map(slice => [slice.emotion, slice]));
const classes = ['anomalous-sd-line-card'];
if (index === ctx.listenIndex) classes.push('is-listen');
if (ctx.picked.has(index)) classes.push('is-picked');
if (ctx.editing === index) classes.push('is-editing');
const card = el('div', classes.join(' '));
card.addEventListener('click', (e) => {
if (!e.target.closest('button, textarea')) ctx.onPick(index, e);
});
if (ctx.showSpeakers) {
card.classList.add('has-speaker');
card.style.setProperty('--speaker', speakerColor(speaker?.group, ctx.groups));
}
const { node: text, box } = lineText(line, index, ctx);
const head = el('div', 'anomalous-sd-line-head');
head.append(el('span', 'anomalous-sd-line-no', `#${index + 1}`));
if (ctx.showSpeakers) {
const chip = button('anomalous-sd-speaker', `${speaker?.character || '?'} ▾`, () => {
openSpeakerMenu(chip, ctx.groups, speaker?.group, key => ctx.onSpeaker(index, key));
}, t('scriptDirectorSpeakerMenu'));
chip.style.setProperty('--speaker', speakerColor(speaker?.group, ctx.groups));
head.append(chip);
}
if (box) {
// Done editing: this card goes back to plain text in place, so a click on another card still lands.
box.addEventListener('blur', () => {
if (ctx.editing !== index) return;
ctx.onEdit(null, true);
card.replaceWith(renderLineCard(line, index, { ...ctx, editing: null }));
});
// Split at the caret: the button keeps the box's focus and caret.
const split = button('anomalous-sd-icon-btn', '✂', async () => {
const parts = splitSegmentAt(box.value, box.selectionStart);
if (!parts) {
await anomalousAlert(t('scriptDirectorSplitHint'));
box.focus();
return;
}
ctx.onSplit(index, parts[0], parts[1]);
}, t('scriptDirectorSplitAtCaret'));
split.addEventListener('mousedown', e => e.preventDefault());
head.append(split);
} else {
head.append(button('anomalous-sd-icon-btn is-edit', '✎', () => ctx.onEdit(index), t('scriptDirectorEditLine')));
}
if (index < ctx.count - 1) {
// Merge keeps this card's emotion and character: one card = one segment spoken with one voice.
head.append(button('anomalous-sd-icon-btn is-edit', '↧', () => ctx.onMerge(index), t('scriptDirectorMergeNext')));
}
if (ctx.canListen) {
head.append(button('anomalous-sd-icon-btn anomalous-sd-line-play', '▶', () => ctx.onListen(index), t('scriptDirectorListenLine')));
}
if (ctx.canRetake) {
const retake = button('anomalous-sd-icon-btn', '↻', () => ctx.onRetake(index), t('scriptRunRetakeLine'));
if (line.take > 1) retake.append(el('span', 'anomalous-sd-take-no', String(line.take)));
head.append(retake);
}
head.append(button('anomalous-sd-icon-btn is-edit is-danger', '×', () => ctx.onDelete(index), t('scriptDirectorDeleteLine')));
if (!ctx.showChips) {
card.append(head, text);
return card;
}
const chips = el('div', 'anomalous-sd-emotion-row');
// The reference clip of the chosen emotion: how this voice sounds, not this line's result.
const reference = button('anomalous-sd-ref-btn', '', () => ctx.onPreview(line, slices, reference),
t('scriptDirectorPreviewEmotion', { emotion: emotionLabel(line.emotion) }));
reference.innerHTML = SPEAKER_ICON;
// A choice this character cannot voice stays visible (and blocks the bundle) instead of being silently reset.
const choices = emotions.includes(line.emotion) ? emotions : [...emotions, line.emotion];
for (const emotion of choices) {
const chip = button('anomalous-sd-emotion-chip', emotionLabel(emotion), () => {
line.emotion = emotion;
chips.querySelectorAll('.anomalous-sd-emotion-chip').forEach(item => item.classList.toggle('active', item === chip));
reference.title = t('scriptDirectorPreviewEmotion', { emotion: emotionLabel(emotion) });
reference.setAttribute('aria-label', reference.title);
reference.disabled = !slices.get(emotion)?.audio_url;
ctx.onEmotion(reference);
});
chip.classList.toggle('active', line.emotion === emotion);
chip.classList.toggle('is-main', emotion === 'main');
if (!emotions.includes(emotion)) {
chip.classList.add('is-missing');
chip.title = t('scriptDirectorEmotionMissing');
}
const sample = slices.get(emotion);
if (sample?.text) chip.title = sample.text;
chips.appendChild(chip);
}
reference.disabled = !slices.get(line.emotion)?.audio_url;
chips.appendChild(reference);
card.append(head, text, chips);
return card;
}