Every page in both languages, all themes and at 480: - Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip). - Keep a task's link chip in its row, its x in reach, and colour the next-task chip. - Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines. - Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read. - Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so. - Right plurals in English, "AI" on the rail, one line left in Chinese translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
423 lines
21 KiB
JavaScript
423 lines
21 KiB
JavaScript
import { t } from './interface_settings.js';
|
||
import { formatSize, importKind, missingForLanguage, rowState, textFromFile, usableAsReference } from './tts_setup_api.js';
|
||
|
||
/**
|
||
* One character card of the import window (and the card of files nobody claims).
|
||
* Folded, a card is one line: name, how many steps are left, how many clips.
|
||
* Unfolded it leads with a checklist (tts_import_groups.js draftChecklist) that
|
||
* lights up the next step and offers its button right there; the chosen weights
|
||
* sit in their checklist lines, then come the clips, line files and language.
|
||
* The import window owns drafts and rows; this module builds each row's elements
|
||
* once (`row.els`), so they keep what was typed when cards are redrawn or a file moves.
|
||
*/
|
||
|
||
export const TRAY = '#unassigned';
|
||
const LANGUAGES = ['', 'ja', 'zh', 'en'];
|
||
const PICKS = { gpt: ['gpt'], sovits: ['sovits'], audio: ['audio'], files: null };
|
||
|
||
/** The card's short status for reasons the checklist does not already show. */
|
||
const SHORT = {
|
||
ttsBatchRowProblem: 'ttsCardFileProblem',
|
||
ttsImportExistingDifferent: 'ttsCardFileProblem',
|
||
ttsImportFailed: 'ttsCardFailed',
|
||
ttsEditorNameInvalid: 'ttsCardEmotionProblem',
|
||
ttsEditorNameDuplicate: 'ttsCardEmotionProblem',
|
||
};
|
||
/** Problems the checklist already shows. */
|
||
const LIST_LIMIT = 60; // clips shown before "show all": a character can bring hundreds
|
||
const IN_CHECKLIST = ['ttsImportWeightsMissing', 'ttsImportNameTaken', 'ttsImportNameTakenVariant', 'ttsBatchNoAudio', 'ttsBatchNoMain',
|
||
'ttsImportNameMissing', 'ttsImportNoFiles', 'ttsBatchDuplicateName', 'ttsImportUnsupportedBlock'];
|
||
|
||
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;
|
||
}
|
||
|
||
function input(className, placeholder) {
|
||
const node = el('input', className);
|
||
node.type = 'text';
|
||
node.placeholder = placeholder;
|
||
return node;
|
||
}
|
||
|
||
/** Name (a renamed file says what it was), state text, size / version / length, and the row's colour. */
|
||
export function updateRow(row) {
|
||
if (!row.els) return;
|
||
const info = row.info || {};
|
||
const from = [row.dir, row.original].filter(Boolean).join('/');
|
||
row.els.name.textContent = row.name;
|
||
row.els.name.title = row.name !== row.original ? t('ttsImportRenamedFrom', { file: from }) : from;
|
||
const parts = row.size ? [formatSize(row.size)] : [];
|
||
if (info.version) parts.push(info.supported ? info.version : t('ttsImportUnsupported', { version: info.version }));
|
||
if (info.seconds !== undefined) parts.push(t('ttsImportSeconds', { seconds: info.seconds }));
|
||
row.els.meta.textContent = parts.join(' · ');
|
||
const outOfRange = row.kind === 'audio' && info.seconds !== undefined && !usableAsReference(info.seconds);
|
||
row.els.root.classList.toggle('is-out-of-range', outOfRange);
|
||
const view = rowState({ error: row.error, uploaded: Boolean(row.spec), queued: row.queued, progress: row.progress,
|
||
existing: info.existing ?? null, unsupported: info.supported === false });
|
||
row.els.root.dataset.tone = view.tone;
|
||
const leftOut = outOfRange && view.tone === 'ready';
|
||
row.els.state.textContent = leftOut ? t('ttsImportLeftOut') : view.tone === 'ready' ? '' : t(view.key, view.params);
|
||
row.els.state.title = leftOut ? t('ttsImportOutOfRange') : t(view.key, view.params);
|
||
}
|
||
|
||
/** Where a clip's line came from, shown as the text box's tooltip and colour. */
|
||
export function showSource(row, source, file = '') {
|
||
if (!row.els?.text) return;
|
||
row.els.text.title = source ? t(`ttsTextSource_${source}`, { file }) : '';
|
||
row.els.text.dataset.source = source || '';
|
||
}
|
||
|
||
/** Mark the main clip of a draft; clips outside 3–10 s cannot be it. */
|
||
export function syncReference(draft) {
|
||
for (const row of draft.rows) {
|
||
if (!row.els?.main) continue;
|
||
const usable = usableAsReference(row.info?.seconds);
|
||
if (!usable && draft.referenceKey === row.key) draft.referenceKey = null;
|
||
const on = row.key === draft.referenceKey;
|
||
row.els.main.setAttribute('aria-pressed', String(on));
|
||
row.els.main.classList.toggle('active', on);
|
||
row.els.main.disabled = !usable;
|
||
row.els.main.title = usable ? t(on ? 'ttsCardMainOn' : 'ttsImportMainTitle') : t('ttsImportOutOfRange');
|
||
row.els.emotion.disabled = on;
|
||
row.els.emotion.placeholder = on ? t('ttsCardMainEmotion') : t('ttsImportEmotionPlaceholder');
|
||
}
|
||
}
|
||
|
||
/** A .txt / .lab / .list dropped on a clip's text box fills that clip's line. */
|
||
function bindTextDrop(row, ctx) {
|
||
const box = row.els.text;
|
||
box.addEventListener('dragover', (e) => {
|
||
if (![...(e.dataTransfer?.types || [])].includes('Files')) return;
|
||
e.preventDefault();
|
||
box.classList.add('dragover');
|
||
});
|
||
box.addEventListener('dragleave', () => box.classList.remove('dragover'));
|
||
box.addEventListener('drop', async (e) => {
|
||
box.classList.remove('dragover');
|
||
const file = [...(e.dataTransfer?.files || [])].find(f => importKind(f.name) === 'text');
|
||
if (!file) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const line = textFromFile(await file.text(), row.name);
|
||
if (ctx.signal.aborted) return;
|
||
if (line === null) { showSource(row, 'missing', file.name); return; }
|
||
box.value = line;
|
||
row.text = line;
|
||
row.textEdited = true;
|
||
showSource(row, 'file', file.name);
|
||
});
|
||
}
|
||
|
||
/** Build a row's elements the first time it is shown. */
|
||
function rowElements(row, ctx) {
|
||
if (row.els) return row.els;
|
||
const root = el('div', `anomalous-tts-file is-${row.kind}`);
|
||
const name = el('span', 'anomalous-tts-file-name', row.name);
|
||
const els = { root, name, meta: el('span', 'anomalous-tts-file-meta'), state: el('span', 'anomalous-tts-file-state'),
|
||
move: el('select', 'anomalous-tts-file-move') };
|
||
row.els = els;
|
||
els.move.title = t('ttsBatchMoveTo');
|
||
els.move.onchange = () => ctx.onMoveRow(row, els.move.value);
|
||
// The full list is built when the menu is about to open: thousands of rows each
|
||
// holding every character would make each redraw slow.
|
||
const fill = () => { if (els.movePlace) fillMoveOptions(els.move, els.movePlace, ctx.places()); };
|
||
els.move.addEventListener('pointerdown', fill);
|
||
els.move.addEventListener('focus', fill);
|
||
const remove = button('anomalous-tts-file-remove', '×', () => ctx.onRemoveRow(row), t('ttsEditorRemove'));
|
||
const label = el('span', 'anomalous-tts-file-label');
|
||
label.append(name, els.meta);
|
||
if (row.kind === 'audio') {
|
||
els.play = button('anomalous-tts-file-play', '▶', () => ctx.play(row), t('audioPlay'));
|
||
els.main = button('anomalous-tts-file-main', t('ttsImportMain'), () => ctx.onReference(row));
|
||
els.emotion = input('anomalous-tts-file-emotion', t('ttsImportEmotionPlaceholder'));
|
||
els.emotion.value = row.emotion;
|
||
els.emotion.oninput = () => { row.emotion = els.emotion.value.trim(); ctx.onEdited(row); };
|
||
els.text = input('anomalous-tts-file-text', t('ttsImportTextPlaceholder'));
|
||
els.text.value = row.text;
|
||
els.text.oninput = () => { row.text = els.text.value.trim(); row.textEdited = true; showSource(row, null); };
|
||
if (row.info && !row.textEdited) showSource(row, row.info.text_source || 'none');
|
||
const top = el('div', 'anomalous-tts-file-top');
|
||
top.append(els.play, label, els.state, els.main, els.move, remove);
|
||
const bottom = el('div', 'anomalous-tts-file-bottom');
|
||
bottom.append(els.emotion, els.text);
|
||
root.append(top, bottom);
|
||
bindTextDrop(row, ctx);
|
||
} else {
|
||
root.append(label, els.state, els.move, remove);
|
||
}
|
||
updateRow(row);
|
||
return els;
|
||
}
|
||
|
||
function fillMoveOptions(select, place, places) {
|
||
if (select.options.length === places.length) return;
|
||
select.replaceChildren();
|
||
for (const option of places) {
|
||
const label = option.id === place.id ? t('ttsCardMoveHere', { name: option.label }) : t('ttsCardMoveTo', { name: option.label });
|
||
const node = el('option', '', label);
|
||
node.value = option.id;
|
||
select.append(node);
|
||
}
|
||
select.value = place.id;
|
||
}
|
||
|
||
/** Where this row can go (the list itself is filled on open); hidden while there is nowhere else. */
|
||
function showRow(row, place, ctx) {
|
||
const els = rowElements(row, ctx);
|
||
const places = ctx.places();
|
||
els.move.hidden = places.length < 2;
|
||
els.movePlace = place;
|
||
const here = places.find(option => option.id === place.id);
|
||
const node = el('option', '', here ? t('ttsCardMoveHere', { name: here.label }) : '');
|
||
node.value = place.id;
|
||
els.move.replaceChildren(node);
|
||
return els.root;
|
||
}
|
||
|
||
function statusOf(view) {
|
||
const key = view.tone === 'ready' ? 'ttsCardReady' : view.tone === 'busy' ? 'ttsCardBusy' : view.tone === 'done' ? 'ttsCardDone'
|
||
: SHORT[view.problem?.[0]] || null;
|
||
const next = view.checklist.find(item => !item.done);
|
||
const text = key ? t(key) : t('ttsCardSteps', { count: view.checklist.filter(item => !item.done).length });
|
||
return { text, title: view.problem ? t(view.problem[0], view.problem[1]) : next ? t(next.key, next.params) : text };
|
||
}
|
||
|
||
/** What a checklist line offers: file buttons, "fill in the name", "pick in the list". */
|
||
function checkActions(item, isNext, draft, view, ctx, parts) {
|
||
const side = el('span', 'anomalous-tts-check-actions');
|
||
if (item.id in PICKS && !item.done) {
|
||
if (isNext) {
|
||
side.append(button('anomalous-tts-pick', t('ttsCardPickFile'), () => ctx.pick(draft, PICKS[item.id])));
|
||
} else {
|
||
side.append(button('anomalous-tts-link', t('ttsCardPickShort'), () => ctx.pick(draft, PICKS[item.id])));
|
||
}
|
||
} else if (item.id === 'main' && !item.done && isNext) {
|
||
side.append(button('anomalous-tts-pick', t('ttsCheckMainGo'), () => parts.clips.scrollIntoView({ block: 'nearest', behavior: 'smooth' })));
|
||
} else if (item.id === 'name' && !item.done) {
|
||
if (view.conflict?.kind === 'exact') side.append(button('anomalous-tts-link', t('ttsImportSwitchToAdd'), () => ctx.onSwitchToAdd(draft)));
|
||
else if (isNext) side.append(button('anomalous-tts-pick', t('ttsCheckNameGo'), () => parts.name?.focus()));
|
||
}
|
||
return side;
|
||
}
|
||
|
||
function renderChecklist(list, draft, view, ctx, parts) {
|
||
list.replaceChildren();
|
||
const next = view.checklist.find(item => !item.done);
|
||
view.checklist.forEach((item, i) => {
|
||
const isNext = item === next;
|
||
const line = el('div', `anomalous-tts-check-item ${item.done ? 'is-done' : isNext ? 'is-next' : 'is-todo'}`);
|
||
line.dataset.check = item.id;
|
||
line.append(el('span', 'anomalous-tts-check-mark', item.done ? '✓' : String(i + 1)), el('span', 'anomalous-tts-check-text', t(item.key, item.params)));
|
||
const weight = (item.id === 'gpt' || item.id === 'sovits') && draft.rows.find(row => row.kind === item.id);
|
||
if (weight) line.append(showRow(weight, draft, ctx));
|
||
else line.append(checkActions(item, isNext, draft, view, ctx, parts));
|
||
list.append(line);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* A character card. `ctx`: `{ status, signal, places(), play(row), pick(draft, kinds, local),
|
||
* onToggle(draft), onName(draft, value), onSwitchToAdd(draft), onReference(row), onEdited(row),
|
||
* onLanguage(draft, value), onMoveRow(row, id), onRemoveRow(row), onRemoveDraft(draft),
|
||
* onDropFiles(draft, dataTransfer) }`. `view`: `{ tone, problem, conflict, problems, checklist }`;
|
||
* `solo` = the only card of the window (always open, cannot be removed). The returned
|
||
* `refresh(view)` repaints status, checklist and problems without touching the fields.
|
||
*/
|
||
export function renderDraftCard(draft, view, ctx, { solo = false } = {}) {
|
||
const open = solo || draft.expanded;
|
||
const card = el('section', `anomalous-tts-card${open ? ' is-open' : ''}${solo ? ' is-solo' : ''}`);
|
||
const head = el('div', 'anomalous-tts-card-head');
|
||
const avatar = el('span', 'anomalous-tts-card-avatar', (draft.target || draft.name).trim().slice(0, 1).toUpperCase() || '?');
|
||
const parts = { name: null, clips: null };
|
||
if (!solo) head.append(button('anomalous-tts-card-chevron', '', () => ctx.onToggle(draft), t(open ? 'ttsCardFold' : 'ttsCardUnfold')));
|
||
head.append(avatar);
|
||
if (draft.target) {
|
||
const title = el('span', 'anomalous-tts-card-title');
|
||
title.append(el('span', 'anomalous-tts-card-addto', t('ttsCardAddTo')), el('span', 'anomalous-tts-card-name', draft.target));
|
||
head.append(title);
|
||
} else {
|
||
const title = input('anomalous-tts-card-name-input', t('ttsCardNamePlaceholder'));
|
||
title.value = draft.name;
|
||
title.title = t('ttsCardNameTitle');
|
||
title.oninput = () => {
|
||
avatar.textContent = title.value.trim().slice(0, 1).toUpperCase() || '?';
|
||
ctx.onName(draft, title.value);
|
||
};
|
||
parts.name = title;
|
||
head.append(title);
|
||
}
|
||
const pill = el('span', 'anomalous-tts-card-pill');
|
||
const clipCount = draft.rows.filter(row => row.kind === 'audio' && !view.skipped.has(row.key)).length;
|
||
head.append(pill, el('span', 'anomalous-tts-card-meta', t('ttsCardClips', { count: clipCount })));
|
||
if (!solo && !draft.target) head.append(button('anomalous-tts-card-remove', '×', () => ctx.onRemoveDraft(draft), t('ttsCardRemove')));
|
||
if (!solo) head.addEventListener('click', (e) => { if (!e.target.closest('input, button')) ctx.onToggle(draft); });
|
||
card.append(head);
|
||
|
||
// Files dropped on a card are this character's.
|
||
card.addEventListener('dragover', (e) => {
|
||
if (![...(e.dataTransfer?.types || [])].includes('Files')) return;
|
||
e.preventDefault();
|
||
card.classList.add('is-drop-target');
|
||
});
|
||
card.addEventListener('dragleave', (e) => { if (!card.contains(e.relatedTarget)) card.classList.remove('is-drop-target'); });
|
||
card.addEventListener('drop', (e) => {
|
||
card.classList.remove('is-drop-target');
|
||
if (![...(e.dataTransfer?.types || [])].includes('Files')) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
ctx.onDropFiles(draft, e.dataTransfer);
|
||
});
|
||
|
||
const checklist = el('div', 'anomalous-tts-check');
|
||
const problems = el('div', 'anomalous-tts-card-problems');
|
||
const paint = (next) => {
|
||
card.dataset.tone = next.tone;
|
||
const status = statusOf(next);
|
||
pill.textContent = status.text;
|
||
pill.title = status.title;
|
||
if (!open) return;
|
||
renderChecklist(checklist, draft, next, ctx, parts);
|
||
problems.replaceChildren();
|
||
if (next.problem && !IN_CHECKLIST.includes(next.problem[0])) {
|
||
problems.append(el('div', 'anomalous-tts-card-problem is-blocking', t(next.problem[0], next.problem[1])));
|
||
}
|
||
for (const text of next.problems) problems.append(el('div', 'anomalous-tts-card-problem', text));
|
||
};
|
||
if (!open) {
|
||
paint(view);
|
||
return { root: card, refresh: paint };
|
||
}
|
||
|
||
const body = el('div', 'anomalous-tts-card-body');
|
||
body.append(checklist);
|
||
|
||
const kept = draft.rows.filter(row => !view.skipped.has(row.key));
|
||
const audioRows = kept.filter(row => row.kind === 'audio');
|
||
const clips = el('div', 'anomalous-tts-card-clips');
|
||
parts.clips = clips;
|
||
if (audioRows.length) {
|
||
const audioHead = el('div', 'anomalous-tts-card-section');
|
||
audioHead.append(el('span', 'anomalous-tts-card-section-title', t('ttsCardClipsTitle', { count: audioRows.length })),
|
||
el('span', 'anomalous-tts-card-section-hint', t('ttsCardClipsHint')),
|
||
button('anomalous-tts-link', t('ttsCardAddClips'), () => ctx.pick(draft, ['audio'])));
|
||
const audioList = el('div', 'anomalous-tts-card-files');
|
||
const shown = draft.showAll ? audioRows : audioRows.filter((row, i) => i < LIST_LIMIT || row.key === draft.referenceKey);
|
||
for (const row of shown) audioList.append(showRow(row, draft, ctx));
|
||
clips.append(audioHead, audioList);
|
||
if (shown.length < audioRows.length) {
|
||
clips.append(button('anomalous-tts-link anomalous-tts-card-more', t('ttsCardShowAll', { count: audioRows.length }), () => ctx.onShowAll(draft)));
|
||
}
|
||
body.append(clips);
|
||
}
|
||
const skippedRows = draft.rows.filter(row => view.skipped.has(row.key));
|
||
if (skippedRows.length) {
|
||
const count = skippedRows.filter(row => row.kind === 'audio').length;
|
||
body.append(foldedRows(skippedRows, draft, ctx, t('ttsCardLeftOut', { count })));
|
||
}
|
||
|
||
const textRows = kept.filter(row => row.kind === 'text');
|
||
const extras = el('div', 'anomalous-tts-card-extras');
|
||
const lines = el('div', 'anomalous-tts-card-lines');
|
||
lines.append(el('span', 'anomalous-tts-card-extra-label', t('ttsCardLineFiles')));
|
||
for (const row of textRows) lines.append(showRow(row, draft, ctx));
|
||
lines.append(button('anomalous-tts-link', t('ttsCardAddLineFile'), () => ctx.pick(draft, ['text'])));
|
||
extras.append(lines);
|
||
if (!draft.target) {
|
||
const languageSelect = el('select', 'anomalous-tts-card-language');
|
||
for (const code of LANGUAGES) {
|
||
const option = el('option', '', code ? t(`ttsNeededFor_${code}`)
|
||
: draft.detectedLanguage ? t('ttsImportLanguageAutoFound', { lang: t(`ttsNeededFor_${draft.detectedLanguage}`) }) : t('ttsImportLanguageAuto'));
|
||
option.value = code;
|
||
languageSelect.append(option);
|
||
}
|
||
languageSelect.value = draft.language;
|
||
languageSelect.onchange = () => ctx.onLanguage(draft, languageSelect.value);
|
||
const language = el('label', 'anomalous-tts-card-lang');
|
||
language.append(el('span', 'anomalous-tts-card-extra-label', t('ttsImportLanguage')), languageSelect);
|
||
extras.append(language);
|
||
}
|
||
body.append(extras);
|
||
// A new character's language decides the language files; any draft's SoVITS model its own files (v4: base + vocoder).
|
||
const needs = draft.rows.flatMap(row => (row.kind === 'sovits' && row.info?.needs) || []);
|
||
const missing = missingForLanguage(ctx.status, draft.target ? '' : draft.language || draft.detectedLanguage || '', needs)
|
||
.filter(item => !draft.target || needs.includes(item.id));
|
||
if (missing.length) {
|
||
body.append(el('div', 'anomalous-tts-card-note', t('ttsImportPretrainedNote', {
|
||
files: missing.map(item => item.label).join(t('listSeparator')),
|
||
size: formatSize(missing.reduce((sum, item) => sum + (item.size || 0), 0)),
|
||
})));
|
||
}
|
||
body.append(problems);
|
||
card.append(body);
|
||
paint(view);
|
||
syncReference(draft);
|
||
return { root: card, refresh: paint };
|
||
}
|
||
|
||
/** Files that did not match any character: each says where it should go. */
|
||
export function renderTrayCard(tray, ctx) {
|
||
const card = el('section', 'anomalous-tts-card is-tray is-open');
|
||
const head = el('div', 'anomalous-tts-card-head');
|
||
head.append(el('span', 'anomalous-tts-card-avatar', '?'),
|
||
el('span', 'anomalous-tts-card-title', t('ttsCardTrayTitle', { count: tray.rows.length })));
|
||
const body = el('div', 'anomalous-tts-card-body');
|
||
body.append(el('div', 'anomalous-tts-card-note', t('ttsCardTrayHint')));
|
||
const folders = new Map();
|
||
for (const row of tray.rows) {
|
||
if (!folders.has(row.dir)) folders.set(row.dir, []);
|
||
folders.get(row.dir).push(row);
|
||
}
|
||
if (tray.rows.length <= LIST_LIMIT && folders.size === 1) {
|
||
const list = el('div', 'anomalous-tts-card-files');
|
||
for (const row of tray.rows) list.append(showRow(row, tray, ctx));
|
||
body.append(list);
|
||
} else {
|
||
// One line per folder: assign the whole folder, or open it to sort file by file.
|
||
for (const [dir, rows] of folders) {
|
||
const fold = foldedRows(rows, tray, ctx, t('ttsCardTrayFolder', { folder: dir || t('ttsCardTrayTop'), count: rows.length }));
|
||
const move = el('select', 'anomalous-tts-file-move');
|
||
move.title = t('ttsBatchMoveTo');
|
||
const first = el('option', '', t('ttsCardTrayMoveAll'));
|
||
first.value = '';
|
||
move.append(first);
|
||
move.addEventListener('pointerdown', () => {
|
||
if (move.options.length > 1) return;
|
||
for (const place of ctx.places().filter(option => option.id !== TRAY)) {
|
||
const node = el('option', '', t('ttsCardMoveTo', { name: place.label }));
|
||
node.value = place.id;
|
||
move.append(node);
|
||
}
|
||
});
|
||
move.onchange = () => { if (move.value) ctx.onMoveRows(rows, move.value); };
|
||
fold.querySelector('summary').append(move);
|
||
body.append(fold);
|
||
}
|
||
}
|
||
card.append(head, body);
|
||
return card;
|
||
}
|
||
|
||
/** Rows folded under a summary line; built the first time it is opened. */
|
||
function foldedRows(rows, place, ctx, label) {
|
||
const box = el('details', 'anomalous-tts-card-fold');
|
||
const summary = el('summary', 'anomalous-tts-card-fold-summary');
|
||
summary.append(el('span', 'anomalous-tts-card-fold-label', label));
|
||
const list = el('div', 'anomalous-tts-card-files');
|
||
box.append(summary, list);
|
||
box.addEventListener('toggle', () => {
|
||
if (box.open && !list.childElementCount) for (const row of rows) list.append(showRow(row, place, ctx));
|
||
});
|
||
return box;
|
||
}
|