Adds a collapsible "🎲 Seed Finder" panel to the voice inspector pane.
Select a voice, expand the panel, set a seed range (default 1–15), and
click Run to generate audio samples with different seeds.
Each result row has Play and "Use this seed" buttons. "Use this seed"
saves the chosen seed to the faster-qwen3-tts voices.json via the new
/api/tts-voice-seed proxy endpoint (which calls /voice-seed on the TTS
server). The saved row gets a ✓ marker.
Test sentence auto-selects DE/EN mixed or EN-only based on voice name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
426 lines
21 KiB
JavaScript
426 lines
21 KiB
JavaScript
// ── Voice inspector (3-pane workbench) ───────────────────────────────────
|
|
let _selectedVoiceWrap = null;
|
|
|
|
function selectVoice(wrap) {
|
|
const inspector = document.getElementById('voices-inspector');
|
|
if (!inspector) return;
|
|
|
|
function restoreToRow(targetWrap) {
|
|
// Restore all extracted elements back to their original DOM parents
|
|
(targetWrap._extracted || []).forEach(({el, target}) => {
|
|
if (el && target) target.appendChild(el);
|
|
});
|
|
targetWrap._extracted = [];
|
|
// Move main-row and optimizer back from inspector body to wrap
|
|
inspector.querySelectorAll('.vr-main-row,.vr-optimizer').forEach(el => targetWrap.appendChild(el));
|
|
}
|
|
|
|
// Deselect previous voice
|
|
if (_selectedVoiceWrap && _selectedVoiceWrap !== wrap) {
|
|
_selectedVoiceWrap.classList.remove('vr-selected');
|
|
restoreToRow(_selectedVoiceWrap);
|
|
}
|
|
|
|
if (_selectedVoiceWrap === wrap) {
|
|
restoreToRow(wrap);
|
|
wrap.classList.remove('vr-selected');
|
|
_selectedVoiceWrap = null;
|
|
inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-account-voice"></span></span><p>Pick a voice on the left<br>to edit it here</p></div>';
|
|
if (typeof window.onMobileInspectorClose === 'function') window.onMobileInspectorClose();
|
|
return;
|
|
}
|
|
|
|
_selectedVoiceWrap = wrap;
|
|
wrap.classList.add('vr-selected');
|
|
if (typeof window.onMobileInspectorOpen === 'function') window.onMobileInspectorOpen();
|
|
|
|
const voiceId = wrap.dataset.id || '';
|
|
const color = wrap.dataset.color || '#9575CD';
|
|
const isClone = wrap.dataset.hasRef === 'true';
|
|
const dbfs = wrap.dataset.dbfs || '-';
|
|
const hasPicture = wrap.dataset.hasPicture === 'true';
|
|
|
|
// Live voice object — authoritative for mutable fields
|
|
const v = (_voices || []).find(vv => vv.id === voiceId) || {};
|
|
const langCode = v.lang || wrap.dataset.lang || '';
|
|
const flagCc = v.flag || wrap.dataset.flagCc || langCode;
|
|
const rating = v.rating || 0;
|
|
|
|
const nameParts = voiceId.split('_');
|
|
// Prefer an explicit display name (e.g. the character name for Rehearser voices);
|
|
// otherwise fall back to the last segment of the ID (EN_F_Anna → "Anna").
|
|
const dispName = (v.name && String(v.name).trim())
|
|
? String(v.name).trim()
|
|
: (nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId);
|
|
const initial = (dispName[0] || voiceId[0] || '?').toUpperCase();
|
|
const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null;
|
|
const inspFlagCc = flagCc || langCode;
|
|
const inspFlagIcon = inspFlagCc
|
|
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
|
|
|
|
const inspIconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 48) : null;
|
|
const avatarHtml = picSrcInsp
|
|
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
|
|
: inspIconHtml || inspFlagIcon || initial;
|
|
const avatarBgStyle = (picSrcInsp || inspIconHtml || inspFlagIcon) ? '' : `style="background:${color}"`;
|
|
|
|
const flagIconHtml = inspFlagIcon || '';
|
|
|
|
const makeStars = n => [1,2,3,4,5].map(i =>
|
|
`<span class="insp-star${i <= n ? ' on' : ''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
|
|
).join('');
|
|
|
|
const inspBenchText = typeof fmtBenchmark === 'function' ? fmtBenchmark(v) : '-';
|
|
const inspBenchTitle = typeof benchmarkTitle === 'function' ? benchmarkTitle(v) : '';
|
|
const inspBenchCls = typeof benchmarkClass === 'function' ? benchmarkClass(v) : '';
|
|
|
|
const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK'];
|
|
|
|
// Gender maps — used in template AND in event handlers
|
|
const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
|
const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
|
const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
|
const curGender = v.gender || 'N';
|
|
const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
|
|
|
|
inspector.innerHTML = `
|
|
<div class="inspector-header">
|
|
<div class="insp-hd-top">
|
|
<div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div>
|
|
<div class="insp-title-stack">
|
|
<div class="insp-hd-row1">
|
|
<h3 class="insp-disp-name" title="Double-click to rename">${escHtml(dispName)}</h3>
|
|
<input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID">
|
|
<div class="insp-actions-save"></div>
|
|
</div>
|
|
<div class="insp-hd-row2">
|
|
<div class="insp-id-block">
|
|
<span class="insp-full-id" title="Click to rename the voice ID">${escHtml(voiceId)}</span>
|
|
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
|
|
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
|
|
</div>
|
|
<div class="insp-actions-active"></div>
|
|
</div>
|
|
<div class="insp-hd-divider"></div>
|
|
<div class="insp-subtitle">
|
|
<span class="insp-flag" title="Click to change accent flag">
|
|
<span class="insp-flag-icon">${flagIconHtml}</span>
|
|
</span>
|
|
<span class="insp-lang-wrap" title="Click to change language">
|
|
<span class="insp-lang-code">${escHtml(langCode)}</span>
|
|
</span>
|
|
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
|
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
|
${inspBenchText !== '-' ? `<span class="insp-bench-chip ${escHtml(inspBenchCls)}" title="${escHtml(inspBenchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(inspBenchText)}</span>` : ''}
|
|
<span class="insp-stars-wrap">
|
|
<span class="insp-stars">${makeStars(rating)}</span>
|
|
<span class="insp-rating-label">${rating}/5</span>
|
|
</span>
|
|
<input class="insp-tag-input" type="text" placeholder="tag1, tag2…" value="${escHtml(v.tag||'')}" autocomplete="off">
|
|
</div>
|
|
<div class="insp-note-delete-row">
|
|
<div class="insp-note-slot"></div>
|
|
<div class="insp-actions-delete"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="inspector-body"></div>
|
|
`;
|
|
|
|
// Inject mobile back button after HTML is set (inspector DOM recreated above)
|
|
if (window.innerWidth <= 767 && typeof window.onMobileInspectorOpen === 'function') {
|
|
window.onMobileInspectorOpen();
|
|
}
|
|
|
|
const saveSlot = inspector.querySelector('.insp-actions-save');
|
|
const activeSlot = inspector.querySelector('.insp-actions-active');
|
|
const deleteSlot = inspector.querySelector('.insp-actions-delete');
|
|
const body = inspector.querySelector('.inspector-body');
|
|
|
|
// ── Save changes → row 1 right ───────────────────────────────────────────
|
|
const saveBtn = document.createElement('button');
|
|
saveBtn.className = 'btn-primary insp-save-btn';
|
|
saveBtn.textContent = 'Save changes';
|
|
saveBtn.addEventListener('click', () => body.querySelector('.opt-save-text')?.click());
|
|
saveSlot.appendChild(saveBtn);
|
|
|
|
// ── Active toggle → row 2 right ──────────────────────────────────────────
|
|
const detailRow = wrap.querySelector('.vr-detail-row');
|
|
const activeEl = detailRow?.querySelector('.vr-detail-active');
|
|
const deleteEl = detailRow?.querySelector('.vr-detail-delete');
|
|
if (activeEl) activeSlot.appendChild(activeEl);
|
|
|
|
// ── Delete → note row right ───────────────────────────────────────────────
|
|
if (deleteEl) {
|
|
deleteSlot.appendChild(deleteEl);
|
|
const _dBtn = deleteEl.querySelector('.delete-btn');
|
|
const _dCnl = deleteEl.querySelector('.delete-confirm-cancel');
|
|
_dBtn?.addEventListener('click', () => deleteEl.classList.add('delete-pending'));
|
|
_dCnl?.addEventListener('click', () => deleteEl.classList.remove('delete-pending'));
|
|
}
|
|
|
|
// ── Avatar click → photo upload ───────────────────────────────────────────
|
|
inspector.querySelector('.inspector-avatar').addEventListener('click', () => {
|
|
body.querySelector('.photo-input')?.click();
|
|
});
|
|
|
|
// ── Copy ID button ────────────────────────────────────────────────────────
|
|
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
|
|
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
|
|
});
|
|
|
|
// ── Double-click name/ID → inline rename ──────────────────────────────────
|
|
const dispNameEl = inspector.querySelector('.insp-disp-name');
|
|
const nameEditEl = inspector.querySelector('.insp-name-edit');
|
|
const fullIdEl = inspector.querySelector('.insp-full-id');
|
|
|
|
const startInspRename = () => {
|
|
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
|
|
nameEditEl.style.display = 'block';
|
|
nameEditEl.value = voiceId; nameEditEl.focus(); nameEditEl.select();
|
|
};
|
|
const commitInspRename = () => {
|
|
dispNameEl.style.display = ''; fullIdEl.style.display = '';
|
|
nameEditEl.style.display = 'none';
|
|
const newId = nameEditEl.value.trim();
|
|
if (!newId || newId === voiceId) return;
|
|
const rowInput = wrap.querySelector('.vr-name-input');
|
|
const rowOk = wrap.querySelector('.rename-ok');
|
|
if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); }
|
|
};
|
|
dispNameEl.addEventListener('dblclick', startInspRename);
|
|
fullIdEl.addEventListener('dblclick', startInspRename);
|
|
fullIdEl.addEventListener('click', startInspRename);
|
|
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
|
|
nameEditEl.addEventListener('blur', commitInspRename);
|
|
nameEditEl.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') nameEditEl.blur();
|
|
if (e.key === 'Escape') { nameEditEl.value = voiceId; nameEditEl.blur(); }
|
|
});
|
|
|
|
// ── Flag (accent/country) — decoupled from language ──────────────────────
|
|
const flagSpan = inspector.querySelector('.insp-flag');
|
|
const flagIconEl = inspector.querySelector('.insp-flag-icon');
|
|
|
|
const applyFlag = async (cc) => {
|
|
v.flag = cc; wrap.dataset.flagCc = cc;
|
|
const fi = `<span class="fi fi-${cc.toLowerCase()}" role="img" aria-label="${cc}"></span>`;
|
|
if (flagIconEl) flagIconEl.innerHTML = fi;
|
|
await saveMeta(voiceId, { flag: cc });
|
|
};
|
|
flagSpan.addEventListener('click', () => {
|
|
// Show this language's accents first, then the complete world list — accent is
|
|
// decoupled from language, so e.g. an English voice can still be set to German.
|
|
const langList = FLAG_OPTIONS[langCode.toUpperCase()] || [];
|
|
const items = uniqueFlagOptions([langList, ALL_FLAGS]).map(([cc, name]) => [cc.toLowerCase(), name]);
|
|
createSearchablePicker(flagSpan, items, applyFlag, {
|
|
placeholder: 'Country or accent…',
|
|
renderItem: (cc, name) =>
|
|
`<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span><span>${escHtml(name)}</span>`,
|
|
});
|
|
});
|
|
|
|
// ── Language — click lang code to change ─────────────────────────────────
|
|
const langCodeEl = inspector.querySelector('.insp-lang-code');
|
|
const langWrap = inspector.querySelector('.insp-lang-wrap');
|
|
|
|
const applyLang = async (newLang) => {
|
|
v.lang = newLang; wrap.dataset.lang = newLang;
|
|
if (langCodeEl) langCodeEl.textContent = newLang;
|
|
await saveMeta(voiceId, { lang: newLang });
|
|
};
|
|
langWrap?.addEventListener('click', () => {
|
|
const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
|
|
createSearchablePicker(langWrap, items, applyLang, {
|
|
placeholder: 'Language…',
|
|
renderItem: (l, label) =>
|
|
`<span class="ipi-code">${escHtml(l)}</span><span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`,
|
|
});
|
|
});
|
|
|
|
// ── Gender label (subtitle) — click to cycle ──────────────────────────────
|
|
const genderLabelEl = inspector.querySelector('.insp-gender-label');
|
|
const genderSel = inspector.querySelector('.insp-gender-sel');
|
|
const applyGender = async (ng) => {
|
|
v.gender = ng;
|
|
if (genderLabelEl) genderLabelEl.textContent = `${_gM[ng]||'?'} ${_gL[ng]||'—'}`;
|
|
if (genderSel) genderSel.value = ng;
|
|
const gBadge = wrap.querySelector('.gender-badge');
|
|
if (gBadge) {
|
|
gBadge.innerHTML = `<span class="gender-sym">${_gM[ng]||'?'}</span><span class="gender-txt">${_gL[ng]||'—'}</span>`;
|
|
gBadge.className = 'gender-badge ' + (_gC[ng]||'g-n');
|
|
}
|
|
await saveMeta(voiceId, { gender: ng });
|
|
};
|
|
genderLabelEl?.addEventListener('click', () => {
|
|
const cycle = ['F','M','N'];
|
|
applyGender(cycle[(cycle.indexOf(v.gender||'N')+1)%3]);
|
|
});
|
|
genderSel?.addEventListener('change', () => applyGender(genderSel.value));
|
|
|
|
// ── Tag input — comma-separated, autocomplete from DB + localStorage ────────
|
|
const tagInput = inspector.querySelector('.insp-tag-input');
|
|
|
|
const getAllKnownTags = () => {
|
|
const fromDb = (_voices || []).flatMap(vv =>
|
|
(vv.tag || '').split(',').map(t => t.trim()).filter(Boolean));
|
|
const fromStorage = getStoredTags();
|
|
return [...new Set([...fromStorage, ...fromDb])].sort((a, b) => a.localeCompare(b));
|
|
};
|
|
const tagLastToken = val => val.split(',').pop().trimStart();
|
|
const tagReplaceLastToken = (val, rep) => {
|
|
const parts = val.split(',');
|
|
parts[parts.length - 1] = parts.length > 1 ? ' ' + rep : rep;
|
|
return parts.join(',');
|
|
};
|
|
|
|
let _tagDrop = null;
|
|
const hideTagDrop = () => { _tagDrop?.remove(); _tagDrop = null; };
|
|
const showTagDrop = () => {
|
|
hideTagDrop();
|
|
const token = tagLastToken(tagInput.value);
|
|
const all = getAllKnownTags();
|
|
const matches = all.filter(t =>
|
|
t.toLowerCase().startsWith(token.toLowerCase()) && t.toLowerCase() !== token.toLowerCase()
|
|
);
|
|
if (!matches.length) return;
|
|
|
|
_tagDrop = document.createElement('div');
|
|
_tagDrop.className = 'tag-suggest';
|
|
matches.slice(0, 10).forEach(tag => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button'; btn.className = 'tag-suggest-item';
|
|
btn.textContent = tag;
|
|
btn.addEventListener('mousedown', e => {
|
|
e.preventDefault();
|
|
tagInput.value = tagReplaceLastToken(tagInput.value, tag);
|
|
tagInput.dispatchEvent(new Event('input'));
|
|
hideTagDrop();
|
|
tagInput.focus();
|
|
});
|
|
_tagDrop.appendChild(btn);
|
|
});
|
|
document.body.appendChild(_tagDrop);
|
|
const r = tagInput.getBoundingClientRect();
|
|
_tagDrop.style.top = (r.bottom + 3) + 'px';
|
|
_tagDrop.style.left = r.left + 'px';
|
|
_tagDrop.style.minWidth = Math.max(r.width, 140) + 'px';
|
|
};
|
|
|
|
tagInput.addEventListener('input', () => showTagDrop());
|
|
tagInput.addEventListener('focus', () => showTagDrop());
|
|
tagInput.addEventListener('blur', () => setTimeout(hideTagDrop, 150));
|
|
tagInput.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') hideTagDrop();
|
|
if (e.key === ',' && _tagDrop) setTimeout(showTagDrop, 10);
|
|
});
|
|
|
|
const saveTagValue = debounce(async () => {
|
|
const tags = tagInput.value.split(',').map(t => t.trim()).filter(Boolean);
|
|
tags.forEach(addStoredTag);
|
|
v.tag = tagInput.value;
|
|
await saveMeta(voiceId, { tag: tagInput.value });
|
|
}, 700);
|
|
tagInput.addEventListener('input', saveTagValue);
|
|
tagInput.addEventListener('change', saveTagValue);
|
|
|
|
// ── Interactive rating (subtitle + meta kept in sync) ─────────────────────
|
|
const updateRating = async (newRating) => {
|
|
v.rating = newRating; wrap.dataset.rating = newRating;
|
|
inspector.querySelectorAll('.insp-star').forEach(s =>
|
|
s.classList.toggle('on', parseInt(s.dataset.val) <= newRating));
|
|
inspector.querySelectorAll('.insp-rating-label, .insp-meta-rating-label').forEach(el =>
|
|
el.textContent = `${newRating}/5`);
|
|
wrap.querySelectorAll('.vr-rating .star').forEach((s, i) =>
|
|
s.classList.toggle('on', i < newRating));
|
|
await saveMeta(voiceId, { rating: newRating });
|
|
};
|
|
['.insp-stars', '.insp-meta-stars'].forEach(sel => {
|
|
const stars = [...inspector.querySelectorAll(`${sel} .insp-star`)];
|
|
stars.forEach(s => {
|
|
s.addEventListener('click', () => {
|
|
const val = parseInt(s.dataset.val);
|
|
updateRating(val === (v.rating||0) ? 0 : val);
|
|
});
|
|
s.addEventListener('mouseenter', () => {
|
|
const val = parseInt(s.dataset.val);
|
|
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= val));
|
|
});
|
|
s.addEventListener('mouseleave', () =>
|
|
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= (v.rating||0))));
|
|
});
|
|
});
|
|
|
|
// ── Note row ──────────────────────────────────────────────────────────────
|
|
const noteEl = detailRow?.querySelector('.vr-note');
|
|
const noteRowEl = inspector.querySelector('.insp-note-slot');
|
|
if (noteEl && noteRowEl) noteRowEl.appendChild(noteEl);
|
|
|
|
// ── Move main-row and optimizer into inspector body ───────────────────────
|
|
const mainRow = wrap.querySelector('.vr-main-row');
|
|
const optimizer = wrap.querySelector('.vr-optimizer');
|
|
if (mainRow) body.appendChild(mainRow);
|
|
if (optimizer) body.appendChild(optimizer);
|
|
|
|
if (wrap._loadOptimizer) wrap._loadOptimizer().catch(e => console.warn('Auto-load waveform failed:', e));
|
|
|
|
// ── Persona panel ─────────────────────────────────────────────────────────
|
|
const personaVal = v.persona || '';
|
|
const personaPanel = document.createElement('div');
|
|
personaPanel.className = 'insp-persona-panel';
|
|
personaPanel.innerHTML = `
|
|
<div class="insp-persona-head">
|
|
<span class="insp-persona-title">Character persona</span>
|
|
<span class="insp-persona-hint">Used by LLM to rewrite text in this voice's style. Leave empty to skip.</span>
|
|
</div>
|
|
<textarea class="insp-persona-input" rows="3" placeholder="e.g. calm British narrator with a warm tone, measured pace, formal vocabulary…">${escHtml(personaVal)}</textarea>
|
|
<div class="insp-persona-actions">
|
|
<button class="btn-primary btn-sm insp-persona-save-btn">Save persona</button>
|
|
<span class="insp-persona-status"></span>
|
|
</div>
|
|
`;
|
|
body.appendChild(personaPanel);
|
|
|
|
const personaTextarea = personaPanel.querySelector('.insp-persona-input');
|
|
const personaStatus = personaPanel.querySelector('.insp-persona-status');
|
|
personaPanel.querySelector('.insp-persona-save-btn').addEventListener('click', async () => {
|
|
const pText = personaTextarea.value.trim();
|
|
try {
|
|
await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ voice_id: voiceId, persona: pText }) });
|
|
v.persona = pText;
|
|
personaStatus.textContent = 'Saved.';
|
|
setTimeout(() => { personaStatus.textContent = ''; }, 2000);
|
|
toast('Persona saved', 'success');
|
|
} catch(e) { toast('Save failed: ' + e.message, 'error'); }
|
|
});
|
|
|
|
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
|
|
if (maintTitle) {
|
|
maintTitle.innerHTML = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`;
|
|
}
|
|
|
|
// ── Collapsible opt-groups ────────────────────────────────────────────────
|
|
body.querySelectorAll('.opt-group').forEach(group => {
|
|
const title = group.querySelector(':scope > .opt-group-title');
|
|
if (!title) return;
|
|
title.addEventListener('click', () => {
|
|
group.classList.toggle('open');
|
|
});
|
|
});
|
|
|
|
// ── Seed Finder panel ────────────────────────────────────────────────────
|
|
if (typeof attachSeedFinder === 'function') {
|
|
attachSeedFinder(voiceId, body);
|
|
}
|
|
|
|
// Track extracted elements for restoreToRow
|
|
wrap._extracted = [
|
|
activeEl ? {el: activeEl, target: detailRow} : null,
|
|
deleteEl ? {el: deleteEl, target: detailRow} : null,
|
|
noteEl ? {el: noteEl, target: detailRow} : null,
|
|
].filter(Boolean);
|
|
|
|
}
|