| 1 |
/* global beyondwordsData */ |
| 2 |
|
| 3 |
/* |
| 4 |
* Classic-editor Customize + Language + Accent + Model + Voice behaviour. Mirrors |
| 5 |
* the block editor's voice-section.js + helpers.js. Language lists names while |
| 6 |
* Accent carries the submitted language CODE. The selects live in the post |
| 7 |
* <form> and submit on save, so no autosave/Heartbeat hook is needed. |
| 8 |
*/ |
| 9 |
( function () { |
| 10 |
'use strict'; |
| 11 |
|
| 12 |
const data = |
| 13 |
typeof beyondwordsData !== 'undefined' ? beyondwordsData : null; |
| 14 |
|
| 15 |
const LANGUAGES = ( data && data.languages ) || []; |
| 16 |
const ELEVENLABS = ( data && data.elevenLabs ) || 'ElevenLabs'; |
| 17 |
const DEFAULT_MODEL_ID = |
| 18 |
( data && data.defaultModelId ) || 'eleven_multilingual_v2'; |
| 19 |
const SELECT_VOICE = ( data && data.selectVoice ) || 'Select a voice'; |
| 20 |
const SELECT_MODEL = ( data && data.selectModel ) || 'Select a model'; |
| 21 |
const STANDARD_MODEL = ( data && data.standardModel ) || 'Legacy'; |
| 22 |
const MODEL_LABELS = ( data && data.voiceModelLabels ) || {}; |
| 23 |
|
| 24 |
// Bucket key for voices without an ElevenLabs model_id (e.g. standard voices). |
| 25 |
const STANDARD_MODEL_KEY = 'standard'; |
| 26 |
|
| 27 |
/** |
| 28 |
* Human label for a model_id slug. |
| 29 |
* |
| 30 |
* @param {string} modelId The model_id slug. |
| 31 |
* |
| 32 |
* @return {string} A display label. |
| 33 |
*/ |
| 34 |
const modelLabel = ( modelId ) => { |
| 35 |
if ( MODEL_LABELS[ modelId ] ) { |
| 36 |
return MODEL_LABELS[ modelId ]; |
| 37 |
} |
| 38 |
return String( modelId ) |
| 39 |
.replace( /^eleven_/, '' ) |
| 40 |
.replace( /_/g, ' ' ) |
| 41 |
.replace( /\b\w/g, ( c ) => c.toUpperCase() ); |
| 42 |
}; |
| 43 |
|
| 44 |
/** |
| 45 |
* The model bucket key for a voice: its ElevenLabs model_id, else Standard. |
| 46 |
* |
| 47 |
* @param {Object} voice A voice record. |
| 48 |
* |
| 49 |
* @return {string} The model bucket key. |
| 50 |
*/ |
| 51 |
const voiceModelKey = ( voice ) => { |
| 52 |
if ( |
| 53 |
voice && |
| 54 |
voice.service === ELEVENLABS && |
| 55 |
typeof voice.model_id === 'string' |
| 56 |
) { |
| 57 |
return voice.model_id; |
| 58 |
} |
| 59 |
return STANDARD_MODEL_KEY; |
| 60 |
}; |
| 61 |
|
| 62 |
/** |
| 63 |
* A voice's primary (native) language code. |
| 64 |
* |
| 65 |
* @param {Object} voice A voice record. |
| 66 |
* |
| 67 |
* @return {string} The primary language code, or ''. |
| 68 |
*/ |
| 69 |
const voicePrimaryCode = ( voice ) => { |
| 70 |
const language = voice && voice.language; |
| 71 |
if ( typeof language === 'string' ) { |
| 72 |
return language; |
| 73 |
} |
| 74 |
if ( language && typeof language === 'object' && language.code ) { |
| 75 |
return language.code; |
| 76 |
} |
| 77 |
return ( |
| 78 |
( voice && voice.languages && voice.languages[ 0 ] |
| 79 |
? voice.languages[ 0 ].code |
| 80 |
: '' ) || '' |
| 81 |
); |
| 82 |
}; |
| 83 |
|
| 84 |
/** |
| 85 |
* Whether a voice is native to a language code. A voice with no |
| 86 |
* determinable primary language is treated as native, so it is never hidden. |
| 87 |
* |
| 88 |
* @param {Object} voice A voice record. |
| 89 |
* @param {string} code The language code. |
| 90 |
* |
| 91 |
* @return {boolean} Whether the voice is native to the code. |
| 92 |
*/ |
| 93 |
const voiceIsNative = ( voice, code ) => { |
| 94 |
const primary = voicePrimaryCode( voice ); |
| 95 |
if ( ! primary ) { |
| 96 |
return true; |
| 97 |
} |
| 98 |
return String( primary ) === String( code ); |
| 99 |
}; |
| 100 |
|
| 101 |
/** |
| 102 |
* The distinct model buckets across a language's voices, for the Model dropdown. |
| 103 |
* |
| 104 |
* @param {Array<Object>} voices All voices for the current language. |
| 105 |
* |
| 106 |
* @return {Array<{key: string, label: string}>} The Model dropdown options. |
| 107 |
*/ |
| 108 |
const languageModels = ( voices ) => { |
| 109 |
const modelIds = []; |
| 110 |
let hasStandard = false; |
| 111 |
|
| 112 |
( voices || [] ).forEach( ( voice ) => { |
| 113 |
const key = voiceModelKey( voice ); |
| 114 |
if ( key === STANDARD_MODEL_KEY ) { |
| 115 |
hasStandard = true; |
| 116 |
} else if ( ! modelIds.includes( key ) ) { |
| 117 |
modelIds.push( key ); |
| 118 |
} |
| 119 |
} ); |
| 120 |
|
| 121 |
modelIds.sort( ( a, b ) => { |
| 122 |
if ( a === DEFAULT_MODEL_ID ) { |
| 123 |
return -1; |
| 124 |
} |
| 125 |
if ( b === DEFAULT_MODEL_ID ) { |
| 126 |
return 1; |
| 127 |
} |
| 128 |
return 0; |
| 129 |
} ); |
| 130 |
|
| 131 |
const models = modelIds.map( ( key ) => ( { |
| 132 |
key, |
| 133 |
label: modelLabel( key ), |
| 134 |
} ) ); |
| 135 |
|
| 136 |
if ( hasStandard ) { |
| 137 |
models.push( { key: STANDARD_MODEL_KEY, label: STANDARD_MODEL } ); |
| 138 |
} |
| 139 |
|
| 140 |
return models; |
| 141 |
}; |
| 142 |
|
| 143 |
const option = ( value, text ) => { |
| 144 |
const el = document.createElement( 'option' ); |
| 145 |
el.value = value; |
| 146 |
el.textContent = text; |
| 147 |
return el; |
| 148 |
}; |
| 149 |
|
| 150 |
const byId = ( id ) => document.getElementById( id ); |
| 151 |
|
| 152 |
/** |
| 153 |
* The language rows (accents) for a language name, in API order. |
| 154 |
* |
| 155 |
* @param {string} name The language name, or ''. |
| 156 |
* |
| 157 |
* @return {Array<Object>} The matching slim language rows. |
| 158 |
*/ |
| 159 |
const accentsForName = ( name ) => |
| 160 |
name ? LANGUAGES.filter( ( language ) => language.name === name ) : []; |
| 161 |
|
| 162 |
/** |
| 163 |
* Find a slim language row by its code. |
| 164 |
* |
| 165 |
* @param {string} code The language code. |
| 166 |
* |
| 167 |
* @return {Object|null} The matching row, or null. |
| 168 |
*/ |
| 169 |
const findLanguageByCode = ( code ) => |
| 170 |
LANGUAGES.find( |
| 171 |
( language ) => String( language.code ) === String( code ) |
| 172 |
) || null; |
| 173 |
|
| 174 |
/** |
| 175 |
* An Accent <option> for a language row: the CODE as its value, carrying |
| 176 |
* the language's default body voice id for the voice-seeding flow. |
| 177 |
* |
| 178 |
* @param {Object} language A slim language row. |
| 179 |
* |
| 180 |
* @return {HTMLOptionElement} The option element. |
| 181 |
*/ |
| 182 |
const accentOption = ( language ) => { |
| 183 |
const el = option( String( language.code ), language.accent ); |
| 184 |
el.setAttribute( |
| 185 |
'data-default-voice-id', |
| 186 |
language.defaultVoiceId ? String( language.defaultVoiceId ) : '' |
| 187 |
); |
| 188 |
return el; |
| 189 |
}; |
| 190 |
|
| 191 |
const toggleLoader = ( show ) => { |
| 192 |
const loader = document.querySelector( |
| 193 |
'.beyondwords-settings__loader' |
| 194 |
); |
| 195 |
if ( loader ) { |
| 196 |
loader.style.display = show ? '' : 'none'; |
| 197 |
} |
| 198 |
}; |
| 199 |
|
| 200 |
const selectVoice = { |
| 201 |
voices: [], |
| 202 |
voicesReq: 0, |
| 203 |
|
| 204 |
init() { |
| 205 |
if ( ! data ) { |
| 206 |
// eslint-disable-next-line no-console |
| 207 |
console.log( '🔊 Unable to retrive WP REST API settings' ); |
| 208 |
return; |
| 209 |
} |
| 210 |
|
| 211 |
const customize = byId( 'beyondwords_customize' ); |
| 212 |
const languageName = byId( 'beyondwords_language_name' ); |
| 213 |
const language = byId( 'beyondwords_language_code' ); |
| 214 |
const native = byId( 'beyondwords_native' ); |
| 215 |
const model = byId( 'beyondwords_model' ); |
| 216 |
|
| 217 |
if ( customize ) { |
| 218 |
customize.addEventListener( 'change', ( event ) => { |
| 219 |
this.toggleCustomize( event.target.checked ); |
| 220 |
} ); |
| 221 |
} |
| 222 |
|
| 223 |
if ( native ) { |
| 224 |
native.addEventListener( 'change', () => { |
| 225 |
const voiceSelect = byId( 'beyondwords_voice_id' ); |
| 226 |
this.renderModels( voiceSelect ? voiceSelect.value : '' ); |
| 227 |
} ); |
| 228 |
} |
| 229 |
|
| 230 |
if ( languageName ) { |
| 231 |
languageName.addEventListener( 'change', ( event ) => { |
| 232 |
this.onLanguageNameChange( event.target.value ); |
| 233 |
} ); |
| 234 |
} |
| 235 |
|
| 236 |
if ( language ) { |
| 237 |
language.addEventListener( 'change', ( event ) => { |
| 238 |
const select = event.target; |
| 239 |
const selected = select.options[ select.selectedIndex ]; |
| 240 |
const defaultVoiceId = selected |
| 241 |
? selected.getAttribute( 'data-default-voice-id' ) |
| 242 |
: ''; |
| 243 |
this.getVoices( select.value, defaultVoiceId ); |
| 244 |
} ); |
| 245 |
} |
| 246 |
|
| 247 |
if ( model ) { |
| 248 |
model.addEventListener( 'change', ( event ) => { |
| 249 |
this.onModelChange( event.target.value ); |
| 250 |
} ); |
| 251 |
} |
| 252 |
|
| 253 |
// Seed this.voices so the Model filter has data before the user interacts. |
| 254 |
this.hydrate(); |
| 255 |
}, |
| 256 |
|
| 257 |
/** |
| 258 |
* Show/hide the language/model/voice fields. |
| 259 |
* |
| 260 |
* Customize off clears the selects so they submit empty and save() |
| 261 |
* removes the meta, reverting the post to the project defaults. |
| 262 |
* |
| 263 |
* @param {boolean} on Whether Customize is enabled. |
| 264 |
*/ |
| 265 |
toggleCustomize( on ) { |
| 266 |
const fields = byId( 'beyondwords-metabox-select-voice--fields' ); |
| 267 |
if ( fields ) { |
| 268 |
fields.style.display = on ? '' : 'none'; |
| 269 |
} |
| 270 |
|
| 271 |
if ( on ) { |
| 272 |
this.applyProjectDefaultLanguage(); |
| 273 |
return; |
| 274 |
} |
| 275 |
|
| 276 |
const languageName = byId( 'beyondwords_language_name' ); |
| 277 |
if ( languageName ) { |
| 278 |
languageName.value = ''; |
| 279 |
} |
| 280 |
|
| 281 |
// Leaves a single empty option, so it submits '' and save() removes the meta. |
| 282 |
this.renderAccents( '', '' ); |
| 283 |
|
| 284 |
this.voices = []; |
| 285 |
this.renderModels( '' ); |
| 286 |
}, |
| 287 |
|
| 288 |
/** |
| 289 |
* Pick a language NAME → rebuild the Accent select, auto-select its |
| 290 |
* first accent and seed that language's default body voice. |
| 291 |
* |
| 292 |
* @param {string} name The language name, or '' for the placeholder. |
| 293 |
*/ |
| 294 |
onLanguageNameChange( name ) { |
| 295 |
const first = this.renderAccents( name, '' ); |
| 296 |
|
| 297 |
if ( first ) { |
| 298 |
this.getVoices( |
| 299 |
String( first.code ), |
| 300 |
first.defaultVoiceId ? String( first.defaultVoiceId ) : '' |
| 301 |
); |
| 302 |
} else { |
| 303 |
this.getVoices( '', '' ); |
| 304 |
} |
| 305 |
}, |
| 306 |
|
| 307 |
/** |
| 308 |
* Rebuild the Accent select for a language name, selecting selectedCode |
| 309 |
* or the first accent. The wrapper is hidden for a single accent, but |
| 310 |
* the select stays mounted so it still submits that accent's code. |
| 311 |
* |
| 312 |
* @param {string} name The language name, or ''. |
| 313 |
* @param {string} selectedCode The language code to select, or ''. |
| 314 |
* |
| 315 |
* @return {Object|null} The selected slim language row, or null. |
| 316 |
*/ |
| 317 |
renderAccents( name, selectedCode ) { |
| 318 |
const wrapper = byId( 'beyondwords-metabox-select-voice--accent' ); |
| 319 |
const accentSelect = byId( 'beyondwords_language_code' ); |
| 320 |
const accents = accentsForName( name ); |
| 321 |
|
| 322 |
let selected = null; |
| 323 |
|
| 324 |
if ( accents.length ) { |
| 325 |
selected = |
| 326 |
accents.find( |
| 327 |
( language ) => |
| 328 |
String( language.code ) === String( selectedCode ) |
| 329 |
) || accents[ 0 ]; |
| 330 |
} |
| 331 |
|
| 332 |
if ( accentSelect ) { |
| 333 |
if ( selected ) { |
| 334 |
accentSelect.replaceChildren( |
| 335 |
...accents.map( accentOption ) |
| 336 |
); |
| 337 |
accentSelect.value = String( selected.code ); |
| 338 |
} else { |
| 339 |
accentSelect.replaceChildren( option( '', '' ) ); |
| 340 |
accentSelect.value = ''; |
| 341 |
} |
| 342 |
} |
| 343 |
|
| 344 |
if ( wrapper ) { |
| 345 |
wrapper.style.display = accents.length > 1 ? '' : 'none'; |
| 346 |
} |
| 347 |
|
| 348 |
return selected; |
| 349 |
}, |
| 350 |
|
| 351 |
/** |
| 352 |
* Programmatically select a language code across the Language + Accent |
| 353 |
* selects and fetch its voices. |
| 354 |
* |
| 355 |
* @param {string} code The language code. |
| 356 |
* @param {string} seedVoiceId The voice id to seed, or '' for none. |
| 357 |
* |
| 358 |
* @return {boolean} Whether the code matched a known language. |
| 359 |
*/ |
| 360 |
selectCode( code, seedVoiceId ) { |
| 361 |
const row = findLanguageByCode( code ); |
| 362 |
|
| 363 |
if ( ! row ) { |
| 364 |
return false; |
| 365 |
} |
| 366 |
|
| 367 |
const nameSelect = byId( 'beyondwords_language_name' ); |
| 368 |
if ( nameSelect ) { |
| 369 |
nameSelect.value = row.name; |
| 370 |
} |
| 371 |
|
| 372 |
this.renderAccents( row.name, String( code ) ); |
| 373 |
this.getVoices( String( code ), seedVoiceId || '' ); |
| 374 |
|
| 375 |
return true; |
| 376 |
}, |
| 377 |
|
| 378 |
/** |
| 379 |
* On Customize-on, fetch the project's default language and pre-select it. |
| 380 |
* |
| 381 |
* Only the language is seeded — the user picks the Model + Voice; on |
| 382 |
* failure we fall back to the manual "pick a language" flow. |
| 383 |
*/ |
| 384 |
applyProjectDefaultLanguage() { |
| 385 |
const language = byId( 'beyondwords_language_code' ); |
| 386 |
|
| 387 |
// Only pre-fill a fresh post; never override an existing choice. |
| 388 |
if ( ! language || language.value || ! data.projectId ) { |
| 389 |
return; |
| 390 |
} |
| 391 |
|
| 392 |
toggleLoader( true ); |
| 393 |
|
| 394 |
const endpoint = `${ data.root }beyondwords/v1/projects/${ data.projectId }`; |
| 395 |
|
| 396 |
window |
| 397 |
.fetch( endpoint, { |
| 398 |
method: 'GET', |
| 399 |
headers: { 'X-WP-Nonce': data.nonce }, |
| 400 |
} ) |
| 401 |
.then( ( response ) => response.json() ) |
| 402 |
.then( ( project ) => { |
| 403 |
// Bail if Customize was switched off, or a language chosen, while |
| 404 |
// in flight — else we'd persist a language on an un-customised post. |
| 405 |
const customize = byId( 'beyondwords_customize' ); |
| 406 |
if ( |
| 407 |
! customize || |
| 408 |
! customize.checked || |
| 409 |
language.value |
| 410 |
) { |
| 411 |
toggleLoader( false ); |
| 412 |
return; |
| 413 |
} |
| 414 |
|
| 415 |
const lang = project && project.language; |
| 416 |
if ( ! lang || ! this.selectCode( String( lang ), '' ) ) { |
| 417 |
toggleLoader( false ); |
| 418 |
} |
| 419 |
} ) |
| 420 |
.catch( () => { |
| 421 |
toggleLoader( false ); |
| 422 |
} ); |
| 423 |
}, |
| 424 |
|
| 425 |
/** |
| 426 |
* Hydrate this.voices for an already-customized saved post. |
| 427 |
* |
| 428 |
* Without it the first Model change runs against empty state and save() |
| 429 |
* drops the stored voice. Loads without clearing, so the server-rendered |
| 430 |
* dropdowns stay put until the fetch re-renders the same selection. |
| 431 |
*/ |
| 432 |
hydrate() { |
| 433 |
const customize = byId( 'beyondwords_customize' ); |
| 434 |
const language = byId( 'beyondwords_language_code' ); |
| 435 |
|
| 436 |
// Only a saved customized post needs hydrating; a fresh one fetches on demand. |
| 437 |
if ( |
| 438 |
! customize || |
| 439 |
! customize.checked || |
| 440 |
! language || |
| 441 |
! language.value |
| 442 |
) { |
| 443 |
return; |
| 444 |
} |
| 445 |
|
| 446 |
const voiceSelect = byId( 'beyondwords_voice_id' ); |
| 447 |
const savedVoiceId = voiceSelect ? voiceSelect.value : ''; |
| 448 |
|
| 449 |
// Disable the Model filter until voices load so a change can't run |
| 450 |
// against empty state; it carries no name, so submits are unaffected. |
| 451 |
const modelSelect = byId( 'beyondwords_model' ); |
| 452 |
if ( modelSelect ) { |
| 453 |
modelSelect.disabled = true; |
| 454 |
} |
| 455 |
|
| 456 |
this.loadVoices( language.value ) |
| 457 |
.then( ( applied ) => { |
| 458 |
if ( applied ) { |
| 459 |
this.renderModels( savedVoiceId ); |
| 460 |
} |
| 461 |
} ) |
| 462 |
.finally( () => { |
| 463 |
if ( modelSelect ) { |
| 464 |
modelSelect.disabled = false; |
| 465 |
} |
| 466 |
} ); |
| 467 |
}, |
| 468 |
|
| 469 |
/** |
| 470 |
* Get voices for a language, then rebuild the Model + Voice dropdowns. |
| 471 |
* |
| 472 |
* A supplied default voice pre-selects its model and voice; otherwise the |
| 473 |
* Model opens on its placeholder and Voice stays hidden until one is picked. |
| 474 |
* |
| 475 |
* @param {string} languageCode The language code. |
| 476 |
* @param {string} defaultVoiceId The language's default body voice id. |
| 477 |
*/ |
| 478 |
getVoices( languageCode, defaultVoiceId ) { |
| 479 |
// Clear the stale UI while the new language's voices resolve. |
| 480 |
this.voices = []; |
| 481 |
this.renderModels( '' ); |
| 482 |
|
| 483 |
this.loadVoices( languageCode ).then( ( applied ) => { |
| 484 |
if ( applied ) { |
| 485 |
this.renderModels( defaultVoiceId ); |
| 486 |
} |
| 487 |
} ); |
| 488 |
}, |
| 489 |
|
| 490 |
/** |
| 491 |
* Fetch a language's voices into this.voices. Does not render. |
| 492 |
* |
| 493 |
* Resolves true when this (latest) fetch applied; false when superseded, |
| 494 |
* on error, or when no language is given. |
| 495 |
* |
| 496 |
* @param {string} languageCode The language code. |
| 497 |
* |
| 498 |
* @return {Promise<boolean>} Whether this fetch applied. |
| 499 |
*/ |
| 500 |
loadVoices( languageCode ) { |
| 501 |
toggleLoader( true ); |
| 502 |
|
| 503 |
if ( ! languageCode ) { |
| 504 |
toggleLoader( false ); |
| 505 |
return Promise.resolve( false ); |
| 506 |
} |
| 507 |
|
| 508 |
// Serialise concurrent fetches — only the latest one applies. |
| 509 |
const reqId = ++this.voicesReq; |
| 510 |
|
| 511 |
const endpoint = `${ data.root }beyondwords/v1/languages/${ languageCode }/voices`; |
| 512 |
|
| 513 |
return window |
| 514 |
.fetch( endpoint, { |
| 515 |
method: 'GET', |
| 516 |
headers: { 'X-WP-Nonce': data.nonce }, |
| 517 |
} ) |
| 518 |
.then( ( response ) => { |
| 519 |
// fetch doesn't reject on HTTP errors; a REST error (e.g. expired |
| 520 |
// nonce → 403) resolves with a JSON object, so throw into the catch. |
| 521 |
if ( ! response.ok ) { |
| 522 |
return response |
| 523 |
.json() |
| 524 |
.catch( () => null ) |
| 525 |
.then( ( body ) => { |
| 526 |
throw new Error( |
| 527 |
( body && body.message ) || |
| 528 |
`HTTP ${ response.status }` |
| 529 |
); |
| 530 |
} ); |
| 531 |
} |
| 532 |
return response.json(); |
| 533 |
} ) |
| 534 |
.then( ( voices ) => { |
| 535 |
if ( reqId !== this.voicesReq ) { |
| 536 |
return false; |
| 537 |
} |
| 538 |
this.voices = Array.isArray( voices ) ? voices : []; |
| 539 |
return true; |
| 540 |
} ) |
| 541 |
.catch( ( error ) => { |
| 542 |
if ( reqId !== this.voicesReq ) { |
| 543 |
return false; |
| 544 |
} |
| 545 |
// eslint-disable-next-line no-console |
| 546 |
console.log( '🔊 Unable to load voices', error ); |
| 547 |
this.voices = []; |
| 548 |
return false; |
| 549 |
} ) |
| 550 |
.finally( () => { |
| 551 |
if ( reqId === this.voicesReq ) { |
| 552 |
toggleLoader( false ); |
| 553 |
} |
| 554 |
} ); |
| 555 |
}, |
| 556 |
|
| 557 |
/** |
| 558 |
* The current voices narrowed by the Native filter. keepId is always |
| 559 |
* kept, so toggling the filter never drops the current selection. |
| 560 |
* |
| 561 |
* @param {string} keepId The voice id to always keep, or ''. |
| 562 |
* |
| 563 |
* @return {Array<Object>} The native-scoped voices. |
| 564 |
*/ |
| 565 |
scopedVoices( keepId ) { |
| 566 |
const nativeSelect = byId( 'beyondwords_native' ); |
| 567 |
const codeSelect = byId( 'beyondwords_language_code' ); |
| 568 |
const nativeFilter = nativeSelect ? nativeSelect.value : 'native'; |
| 569 |
const code = codeSelect ? codeSelect.value : ''; |
| 570 |
|
| 571 |
let result = |
| 572 |
nativeFilter === 'all' |
| 573 |
? this.voices |
| 574 |
: this.voices.filter( ( voice ) => |
| 575 |
voiceIsNative( voice, code ) |
| 576 |
); |
| 577 |
|
| 578 |
if ( |
| 579 |
keepId && |
| 580 |
! result.some( |
| 581 |
( voice ) => String( voice.id ) === String( keepId ) |
| 582 |
) |
| 583 |
) { |
| 584 |
const saved = this.voices.find( |
| 585 |
( voice ) => String( voice.id ) === String( keepId ) |
| 586 |
); |
| 587 |
if ( saved ) { |
| 588 |
result = result.concat( [ saved ] ); |
| 589 |
} |
| 590 |
} |
| 591 |
|
| 592 |
return result; |
| 593 |
}, |
| 594 |
|
| 595 |
/** |
| 596 |
* Rebuild the Model dropdown from the native-scoped voices, then the Voice |
| 597 |
* dropdown. The Model dropdown is hidden when the scoped set offers one bucket. |
| 598 |
* |
| 599 |
* @param {string} selectedVoiceId The voice id to pre-select, or ''. |
| 600 |
*/ |
| 601 |
renderModels( selectedVoiceId ) { |
| 602 |
const modelWrapper = byId( |
| 603 |
'beyondwords-metabox-select-voice--model' |
| 604 |
); |
| 605 |
const modelSelect = byId( 'beyondwords_model' ); |
| 606 |
|
| 607 |
const voices = this.scopedVoices( selectedVoiceId ); |
| 608 |
const models = languageModels( voices ); |
| 609 |
const showModel = models.length > 1; |
| 610 |
|
| 611 |
if ( modelSelect ) { |
| 612 |
modelSelect.replaceChildren( |
| 613 |
option( '', SELECT_MODEL ), |
| 614 |
...models.map( ( model ) => |
| 615 |
option( model.key, model.label ) |
| 616 |
) |
| 617 |
); |
| 618 |
} |
| 619 |
|
| 620 |
const selectedVoice = selectedVoiceId |
| 621 |
? voices.find( |
| 622 |
( voice ) => |
| 623 |
String( voice.id ) === String( selectedVoiceId ) |
| 624 |
) |
| 625 |
: null; |
| 626 |
const selectedKey = selectedVoice |
| 627 |
? voiceModelKey( selectedVoice ) |
| 628 |
: ''; |
| 629 |
|
| 630 |
if ( modelSelect ) { |
| 631 |
modelSelect.value = showModel ? selectedKey : ''; |
| 632 |
} |
| 633 |
if ( modelWrapper ) { |
| 634 |
modelWrapper.style.display = showModel ? '' : 'none'; |
| 635 |
} |
| 636 |
|
| 637 |
this.renderVoices( |
| 638 |
selectedKey, |
| 639 |
selectedVoiceId, |
| 640 |
showModel, |
| 641 |
voices |
| 642 |
); |
| 643 |
}, |
| 644 |
|
| 645 |
/** |
| 646 |
* Rebuild the Voice dropdown for a model bucket and select a voice. |
| 647 |
* |
| 648 |
* Hidden while gated with no model chosen; a single bucket lists every voice. |
| 649 |
* |
| 650 |
* @param {string} modelKey The selected model bucket key, or ''. |
| 651 |
* @param {string} preselectId The voice id to select if in the bucket. |
| 652 |
* @param {boolean} showModel Whether the Model dropdown is shown. |
| 653 |
* @param {Array<Object>} voices The native-scoped voices to list. |
| 654 |
*/ |
| 655 |
renderVoices( modelKey, preselectId, showModel, voices ) { |
| 656 |
const voiceWrapper = byId( |
| 657 |
'beyondwords-metabox-select-voice--voice-id' |
| 658 |
); |
| 659 |
const voiceSelect = byId( 'beyondwords_voice_id' ); |
| 660 |
|
| 661 |
if ( showModel && '' === modelKey ) { |
| 662 |
if ( voiceSelect ) { |
| 663 |
voiceSelect.replaceChildren( option( '', SELECT_VOICE ) ); |
| 664 |
voiceSelect.value = ''; |
| 665 |
} |
| 666 |
if ( voiceWrapper ) { |
| 667 |
voiceWrapper.style.display = 'none'; |
| 668 |
} |
| 669 |
return; |
| 670 |
} |
| 671 |
|
| 672 |
const bucketVoices = showModel |
| 673 |
? voices.filter( |
| 674 |
( voice ) => voiceModelKey( voice ) === modelKey |
| 675 |
) |
| 676 |
: voices; |
| 677 |
|
| 678 |
if ( voiceSelect ) { |
| 679 |
voiceSelect.replaceChildren( |
| 680 |
option( '', SELECT_VOICE ), |
| 681 |
...bucketVoices.map( ( voice ) => |
| 682 |
option( String( voice.id ), voice.name ) |
| 683 |
) |
| 684 |
); |
| 685 |
|
| 686 |
const inBucket = bucketVoices.some( |
| 687 |
( voice ) => String( voice.id ) === String( preselectId ) |
| 688 |
); |
| 689 |
voiceSelect.value = inBucket ? String( preselectId ) : ''; |
| 690 |
} |
| 691 |
|
| 692 |
if ( voiceWrapper ) { |
| 693 |
voiceWrapper.style.display = bucketVoices.length ? '' : 'none'; |
| 694 |
} |
| 695 |
}, |
| 696 |
|
| 697 |
/** |
| 698 |
* Pick a model: list that bucket's voices and select the first. |
| 699 |
* |
| 700 |
* A concrete voice id is always submitted (the voice carries the model). |
| 701 |
* |
| 702 |
* @param {string} modelKey The selected model bucket key. |
| 703 |
*/ |
| 704 |
onModelChange( modelKey ) { |
| 705 |
const voiceSelect = byId( 'beyondwords_voice_id' ); |
| 706 |
const voices = this.scopedVoices( |
| 707 |
voiceSelect ? voiceSelect.value : '' |
| 708 |
); |
| 709 |
|
| 710 |
if ( ! modelKey ) { |
| 711 |
this.renderVoices( '', '', true, voices ); |
| 712 |
return; |
| 713 |
} |
| 714 |
const first = voices.find( |
| 715 |
( voice ) => voiceModelKey( voice ) === modelKey |
| 716 |
); |
| 717 |
this.renderVoices( |
| 718 |
modelKey, |
| 719 |
first ? String( first.id ) : '', |
| 720 |
true, |
| 721 |
voices |
| 722 |
); |
| 723 |
}, |
| 724 |
}; |
| 725 |
|
| 726 |
if ( document.readyState !== 'loading' ) { |
| 727 |
selectVoice.init(); |
| 728 |
} else { |
| 729 |
document.addEventListener( 'DOMContentLoaded', () => |
| 730 |
selectVoice.init() |
| 731 |
); |
| 732 |
} |
| 733 |
} )(); |
| 734 |
|