Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_script_cards.js
T

245 lines
11 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.
* A line set to read in its own way shows a tag with what it set (ui_script_tune.js).
* The director owns the lines; a card only calls back (`ctx`, see renderLineCard).
*/
const SEARCH_FROM = 9; // characters from which the menu has a search box
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 `anchor` (a card's name chip, the side panel's "Other
* characters"); `choose(key)`. `shown`: the characters listed (all of `groups` by default; the
* colours stay by place in `groups`). Many of them get a search box.
*/
export function openSpeakerMenu(anchor, groups, current, choose, shown = groups) {
closeSpeakerMenu();
const menu = el('div', 'anomalous-sd-speaker-menu');
menu.setAttribute('role', 'menu'); // the browser's Esc leaves an open menu alone
const items = shown.map((group) => {
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));
return [group, item];
});
if (shown.length >= SEARCH_FROM) {
const search = el('input', 'anomalous-sd-speaker-search');
search.type = 'search';
search.placeholder = t('scriptDirectorSpeakerSearch');
search.oninput = () => {
const term = search.value.trim().toLowerCase();
for (const [group, item] of items) item.hidden = Boolean(term) && !group.character.toLowerCase().includes(term);
};
search.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
items.find(([, item]) => !item.hidden)?.[1].click();
});
menu.append(search);
requestAnimationFrame(() => search.focus());
}
menu.append(...items.map(([, item]) => item));
const rect = anchor.getBoundingClientRect();
menu.style.left = `${Math.round(Math.min(rect.left, window.innerWidth - 200))}px`;
menu.style.top = `${Math.round(rect.bottom + 4)}px`;
document.body.append(menu);
// Below the anchor when it fits, else above it.
const height = menu.getBoundingClientRect().height;
if (rect.bottom + 4 + height > window.innerHeight && rect.top - 4 - height > 0) menu.style.top = `${Math.round(rect.top - 4 - height)}px`;
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`, `tuneText(line)`, 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);
}
const tune = ctx.tuneText?.(line);
if (tune) {
const tag = el('span', 'anomalous-sd-line-tune', tune);
tag.title = t('scriptCardTuneHint');
head.append(tag);
}
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;
}