Spaces:
Running
Running
| <script lang="ts"> | |
| import { onMount, tick } from "svelte"; | |
| import { afterNavigate } from "$app/navigation"; | |
| import { DropdownMenu } from "bits-ui"; | |
| import CarbonAdd from "~icons/carbon/add"; | |
| import CarbonImage from "~icons/carbon/image"; | |
| import CarbonDocument from "~icons/carbon/document"; | |
| import CarbonUpload from "~icons/carbon/upload"; | |
| import CarbonLink from "~icons/carbon/link"; | |
| import CarbonChevronRight from "~icons/carbon/chevron-right"; | |
| import UrlFetchModal from "./UrlFetchModal.svelte"; | |
| import { TEXT_MIME_ALLOWLIST, IMAGE_MIME_ALLOWLIST_DEFAULT } from "$lib/constants/mime"; | |
| import { isVirtualKeyboard } from "$lib/utils/isVirtualKeyboard"; | |
| import { requireAuthUser } from "$lib/utils/auth"; | |
| interface Props { | |
| files?: File[]; | |
| mimeTypes?: string[]; | |
| value?: string; | |
| placeholder?: string; | |
| loading?: boolean; | |
| disabled?: boolean; | |
| // tools removed | |
| modelIsMultimodal?: boolean; | |
| children?: import("svelte").Snippet; | |
| onPaste?: (e: ClipboardEvent) => void; | |
| focused?: boolean; | |
| onsubmit?: () => void; | |
| } | |
| let { | |
| files = $bindable([]), | |
| mimeTypes = [], | |
| value = $bindable(""), | |
| placeholder = "", | |
| loading = false, | |
| disabled = false, | |
| modelIsMultimodal = false, | |
| children, | |
| onPaste, | |
| focused = $bindable(false), | |
| onsubmit, | |
| }: Props = $props(); | |
| const onFileChange = async (e: Event) => { | |
| if (!e.target) return; | |
| const target = e.target as HTMLInputElement; | |
| const selected = Array.from(target.files ?? []); | |
| if (selected.length === 0) return; | |
| files = [...files, ...selected]; | |
| await tick(); | |
| void focusTextarea(); | |
| }; | |
| let textareaElement: HTMLTextAreaElement | undefined = $state(); | |
| let isCompositionOn = $state(false); | |
| let blurTimeout: ReturnType<typeof setTimeout> | null = $state(null); | |
| let fileInputEl: HTMLInputElement | undefined = $state(); | |
| let isUrlModalOpen = $state(false); | |
| function openPickerWithAccept(accept: string) { | |
| if (!fileInputEl) return; | |
| const allAccept = mimeTypes.join(","); | |
| fileInputEl.setAttribute("accept", accept); | |
| fileInputEl.click(); | |
| queueMicrotask(() => fileInputEl?.setAttribute("accept", allAccept)); | |
| } | |
| function openFilePickerText() { | |
| const textAccept = | |
| mimeTypes.filter((m) => !(m === "image/*" || m.startsWith("image/"))).join(",") || | |
| TEXT_MIME_ALLOWLIST.join(","); | |
| openPickerWithAccept(textAccept); | |
| } | |
| function openFilePickerImage() { | |
| const imageAccept = | |
| mimeTypes.filter((m) => m === "image/*" || m.startsWith("image/")).join(",") || | |
| IMAGE_MIME_ALLOWLIST_DEFAULT.join(","); | |
| openPickerWithAccept(imageAccept); | |
| } | |
| const waitForAnimationFrame = () => | |
| typeof requestAnimationFrame === "function" | |
| ? new Promise<void>((resolve) => { | |
| requestAnimationFrame(() => resolve()); | |
| }) | |
| : Promise.resolve(); | |
| async function focusTextarea() { | |
| if (!textareaElement || textareaElement.disabled || isVirtualKeyboard()) return; | |
| if (typeof document !== "undefined" && document.activeElement === textareaElement) return; | |
| await tick(); | |
| if (typeof requestAnimationFrame === "function") { | |
| await waitForAnimationFrame(); | |
| await waitForAnimationFrame(); | |
| } | |
| if (!textareaElement || textareaElement.disabled || isVirtualKeyboard()) return; | |
| try { | |
| textareaElement.focus({ preventScroll: true }); | |
| } catch { | |
| textareaElement.focus(); | |
| } | |
| } | |
| function handleFetchedFiles(newFiles: File[]) { | |
| if (!newFiles?.length) return; | |
| files = [...files, ...newFiles]; | |
| queueMicrotask(async () => { | |
| await tick(); | |
| void focusTextarea(); | |
| }); | |
| } | |
| onMount(() => { | |
| void focusTextarea(); | |
| }); | |
| afterNavigate(() => { | |
| void focusTextarea(); | |
| }); | |
| function adjustTextareaHeight() { | |
| if (!textareaElement) { | |
| return; | |
| } | |
| textareaElement.style.height = "auto"; | |
| textareaElement.style.height = `${textareaElement.scrollHeight}px`; | |
| if (textareaElement.selectionStart === textareaElement.value.length) { | |
| textareaElement.scrollTop = textareaElement.scrollHeight; | |
| } | |
| } | |
| $effect(() => { | |
| if (!textareaElement) return; | |
| void value; | |
| adjustTextareaHeight(); | |
| }); | |
| function handleKeydown(event: KeyboardEvent) { | |
| if ( | |
| event.key === "Enter" && | |
| !event.shiftKey && | |
| !isCompositionOn && | |
| !isVirtualKeyboard() && | |
| value.trim() !== "" | |
| ) { | |
| event.preventDefault(); | |
| tick(); | |
| onsubmit?.(); | |
| } | |
| } | |
| function handleFocus() { | |
| if (blurTimeout) { | |
| clearTimeout(blurTimeout); | |
| blurTimeout = null; | |
| } | |
| focused = true; | |
| } | |
| function handleBlur() { | |
| if (!isVirtualKeyboard()) { | |
| focused = false; | |
| return; | |
| } | |
| if (blurTimeout) { | |
| clearTimeout(blurTimeout); | |
| } | |
| blurTimeout = setTimeout(() => { | |
| blurTimeout = null; | |
| focused = false; | |
| }); | |
| } | |
| // Show file upload when any mime is allowed (text always; images if multimodal) | |
| let showFileUpload = $derived(mimeTypes.length > 0); | |
| let showNoTools = $derived(!showFileUpload); | |
| </script> | |
| <div class="flex min-h-full flex-1 flex-col" onpaste={onPaste}> | |
| <textarea | |
| rows="1" | |
| tabindex="0" | |
| inputmode="text" | |
| class="scrollbar-custom max-h-[4lh] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-2.5 py-2.5 outline-none focus:ring-0 focus-visible:ring-0 sm:px-3 md:max-h-[8lh]" | |
| class:text-gray-400={disabled} | |
| bind:value | |
| bind:this={textareaElement} | |
| onkeydown={handleKeydown} | |
| oncompositionstart={() => (isCompositionOn = true)} | |
| oncompositionend={() => (isCompositionOn = false)} | |
| {placeholder} | |
| {disabled} | |
| onfocus={handleFocus} | |
| onblur={handleBlur} | |
| onbeforeinput={requireAuthUser} | |
| ></textarea> | |
| {#if !showNoTools} | |
| <div | |
| class={[ | |
| "scrollbar-custom -ml-0.5 flex max-w-[calc(100%-40px)] flex-wrap items-center justify-start gap-2.5 px-3 pb-2.5 pt-1.5 text-gray-500 dark:text-gray-400 max-md:flex-nowrap max-md:overflow-x-auto sm:gap-2", | |
| ]} | |
| > | |
| {#if showFileUpload} | |
| <div class="flex items-center"> | |
| <input | |
| bind:this={fileInputEl} | |
| disabled={loading} | |
| class="absolute hidden size-0" | |
| aria-label="Upload file" | |
| type="file" | |
| onchange={onFileChange} | |
| onclick={(e) => { | |
| if (requireAuthUser()) { | |
| e.preventDefault(); | |
| } | |
| }} | |
| accept={mimeTypes.join(",")} | |
| /> | |
| <DropdownMenu.Root> | |
| <DropdownMenu.Trigger | |
| class="btn size-7 rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600" | |
| disabled={loading} | |
| aria-label="Add attachment" | |
| > | |
| <CarbonAdd class="text-base" /> | |
| </DropdownMenu.Trigger> | |
| <DropdownMenu.Portal> | |
| <DropdownMenu.Content | |
| class="z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100 dark:supports-[backdrop-filter]:bg-gray-800/80" | |
| side="top" | |
| sideOffset={8} | |
| align="start" | |
| > | |
| {#if modelIsMultimodal} | |
| <DropdownMenu.Item | |
| class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10" | |
| onSelect={() => openFilePickerImage()} | |
| > | |
| <CarbonImage class="size-4 opacity-90 dark:opacity-80" /> | |
| Add image | |
| </DropdownMenu.Item> | |
| {/if} | |
| <DropdownMenu.Sub> | |
| <DropdownMenu.SubTrigger | |
| class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10" | |
| > | |
| <div class="flex items-center gap-1"> | |
| <CarbonDocument class="size-4 opacity-90 dark:opacity-80" /> | |
| Add text file | |
| </div> | |
| <div class="ml-auto flex items-center"> | |
| <CarbonChevronRight class="size-4 opacity-70 dark:opacity-80" /> | |
| </div> | |
| </DropdownMenu.SubTrigger> | |
| <DropdownMenu.SubContent | |
| class="z-50 rounded-xl border border-gray-200 bg-white/95 p-1 text-gray-800 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700/60 dark:bg-gray-800/95 dark:text-gray-100 dark:supports-[backdrop-filter]:bg-gray-800/80" | |
| sideOffset={10} | |
| > | |
| <DropdownMenu.Item | |
| class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10" | |
| onSelect={() => openFilePickerText()} | |
| > | |
| <CarbonUpload class="size-4 opacity-90 dark:opacity-80" /> | |
| Upload from device | |
| </DropdownMenu.Item> | |
| <DropdownMenu.Item | |
| class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10" | |
| onSelect={() => (isUrlModalOpen = true)} | |
| > | |
| <CarbonLink class="size-4 opacity-90 dark:opacity-80" /> | |
| Fetch from URL | |
| </DropdownMenu.Item> | |
| </DropdownMenu.SubContent> | |
| </DropdownMenu.Sub> | |
| </DropdownMenu.Content> | |
| </DropdownMenu.Portal> | |
| </DropdownMenu.Root> | |
| </div> | |
| {/if} | |
| </div> | |
| {/if} | |
| {@render children?.()} | |
| <UrlFetchModal | |
| bind:open={isUrlModalOpen} | |
| acceptMimeTypes={mimeTypes} | |
| onfiles={handleFetchedFiles} | |
| /> | |
| </div> | |
| <style lang="postcss"> | |
| :global(pre), | |
| :global(textarea) { | |
| font-family: inherit; | |
| box-sizing: border-box; | |
| line-height: 1.5; | |
| font-size: 16px; | |
| } | |
| </style> | |