open-webui/src/lib/components/workspace/Models/TTSVoiceInput.svelte
Timothy Jaeryang Baek f677fdbf50 refac
2026-09-03 20:22:32 -04:00

201 lines
5.1 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, getContext, tick } from 'svelte';
type Voice = {
id: string;
name?: string;
description?: string;
meta?: {
description?: string;
};
};
export let id = 'tts-voice';
export let value = '';
export let voices: Voice[] = [];
export let placeholder = '';
export let className = 'w-full';
export let selectedIds: string[] | null = null;
const i18n: any = getContext('i18n');
const dispatch = createEventDispatcher<{
select: Voice;
enableall: Voice[];
}>();
const listboxId = `${id}-options`;
let inputElement: HTMLInputElement | null = null;
let popupElement: HTMLDivElement | null = null;
let suggestionsOpen = false;
$: query = value.trim().toLowerCase();
$: matchedVoices = (voices ?? []).filter((voice) => {
const id = voice.id.toLowerCase();
const name = (voice.name ?? '').toLowerCase();
const description = (voice.description ?? voice.meta?.description ?? '').toLowerCase();
return (
query === '' || id.includes(query) || name.includes(query) || description.includes(query)
);
});
$: optionVoices = selectedIds === null ? matchedVoices.slice(0, 8) : matchedVoices;
$: if (suggestionsOpen && optionVoices.length > 0) {
tick().then(positionPopup);
}
const portal = (node: HTMLElement) => {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
};
const positionPopup = () => {
if (!inputElement || !popupElement) return;
const rect = inputElement.getBoundingClientRect();
const width = Math.min(192, rect.width, window.innerWidth - 16);
popupElement.style.top = `${rect.bottom + 4}px`;
popupElement.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`;
popupElement.style.width = `${width}px`;
};
const selectVoice = (voice: Voice) => {
if (selectedIds === null) {
value = voice.id;
}
suggestionsOpen = false;
dispatch('select', voice);
};
const enableVoices = () => {
suggestionsOpen = false;
dispatch('enableall', matchedVoices);
};
const handlePointerDown = (event: PointerEvent) => {
if (!suggestionsOpen) return;
const target = event.target as Node;
if (inputElement?.contains(target) || popupElement?.contains(target)) {
return;
}
suggestionsOpen = false;
};
</script>
<svelte:window
on:pointerdown={handlePointerDown}
on:scroll|capture={positionPopup}
on:resize={positionPopup}
/>
<input
bind:this={inputElement}
bind:value
id={`${id}-input`}
class="{className} text-sm bg-transparent outline-hidden"
type="text"
{placeholder}
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={suggestionsOpen && optionVoices.length > 0}
autocomplete="off"
on:focus={() => {
suggestionsOpen = true;
positionPopup();
}}
on:click={() => {
suggestionsOpen = true;
positionPopup();
}}
on:input={() => {
suggestionsOpen = true;
positionPopup();
}}
on:keydown={(event) => {
if (event.key === 'Escape') {
suggestionsOpen = false;
}
}}
on:blur={() => {
setTimeout(() => {
if (!popupElement?.contains(document.activeElement)) {
suggestionsOpen = false;
}
}, 0);
}}
/>
{#if suggestionsOpen && optionVoices.length > 0}
<div
use:portal
bind:this={popupElement}
id={listboxId}
class="fixed max-h-48 overflow-y-auto rounded-2xl border border-gray-200 bg-white p-0.5 shadow-lg dark:border-gray-800 dark:bg-gray-850"
role="listbox"
style="z-index: 9999; top: 0; left: 0;"
>
{#if selectedIds !== null}
{#if matchedVoices.length > 0}
<button
type="button"
class="flex w-full items-center justify-between gap-3 rounded-xl px-2 py-[0.3125rem] text-left text-xs text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800"
role="option"
on:mousedown={(event) => {
event.preventDefault();
}}
on:click={() => {
enableVoices();
}}
>
<span class="truncate"
>{$i18n.t('Enable all ({{count}})', { count: matchedVoices.length })}</span
>
</button>
{/if}
{/if}
{#each optionVoices as voice (voice.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-3 rounded-xl px-2 py-[0.3125rem] text-left text-xs text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800"
role="option"
aria-selected={selectedIds?.includes(voice.id) ?? value === voice.id}
on:mousedown={(event) => {
event.preventDefault();
}}
on:click={() => {
selectVoice(voice);
}}
>
<span class="truncate">{voice.id}</span>
{#if voice.name && voice.name !== voice.id}
<span class="truncate text-gray-500 dark:text-gray-400">{voice.name}</span>
{/if}
{#if selectedIds !== null && selectedIds.includes(voice.id)}
<svg
class="h-3.5 w-3.5 shrink-0 text-gray-500 dark:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="m5 12 4.7 4.5 9.3-9"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}