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 = ''; 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; }