|
<script lang="ts"> |
|
import { |
|
beforeUpdate, |
|
afterUpdate, |
|
createEventDispatcher, |
|
tick |
|
} from "svelte"; |
|
import { text_area_resize, resize } from "../shared/utils"; |
|
import { BlockTitle } from "@gradio/atoms"; |
|
import { Upload } from "@gradio/upload"; |
|
import { Image } from "@gradio/image/shared"; |
|
import type { FileData, Client } from "@gradio/client"; |
|
import { |
|
Clear, |
|
File, |
|
Music, |
|
Paperclip, |
|
Video, |
|
Send, |
|
Square |
|
} from "@gradio/icons"; |
|
import type { SelectData } from "@gradio/utils"; |
|
|
|
export let value: { text: string; files: FileData[] } = { |
|
text: "", |
|
files: [] |
|
}; |
|
|
|
export let value_is_output = false; |
|
export let lines = 1; |
|
export let placeholder = "Type here..."; |
|
export let disabled = false; |
|
export let label: string; |
|
export let info: string | undefined = undefined; |
|
export let show_label = true; |
|
export let container = true; |
|
export let max_lines: number; |
|
export let submit_btn: string | boolean | null = null; |
|
export let stop_btn: string | boolean | null = null; |
|
export let rtl = false; |
|
export let autofocus = false; |
|
export let text_align: "left" | "right" | undefined = undefined; |
|
export let autoscroll = true; |
|
export let root: string; |
|
export let file_types: string[] | null = null; |
|
export let max_file_size: number | null = null; |
|
export let upload: Client["upload"]; |
|
export let stream_handler: Client["stream"]; |
|
export let file_count: "single" | "multiple" | "directory" = "multiple"; |
|
|
|
let upload_component: Upload; |
|
let hidden_upload: HTMLInputElement; |
|
let el: HTMLTextAreaElement | HTMLInputElement; |
|
let can_scroll: boolean; |
|
let previous_scroll_top = 0; |
|
let user_has_scrolled_up = false; |
|
export let dragging = false; |
|
let uploading = false; |
|
let oldValue = value.text; |
|
$: dispatch("drag", dragging); |
|
|
|
let full_container: HTMLDivElement; |
|
|
|
$: if (oldValue !== value.text) { |
|
dispatch("change", value); |
|
oldValue = value.text; |
|
} |
|
|
|
$: if (value === null) value = { text: "", files: [] }; |
|
$: value, el && lines !== max_lines && resize(el, lines, max_lines); |
|
$: can_submit = value.text !== "" || value.files.length > 0; |
|
|
|
const dispatch = createEventDispatcher<{ |
|
change: typeof value; |
|
submit: undefined; |
|
stop: undefined; |
|
blur: undefined; |
|
select: SelectData; |
|
input: undefined; |
|
focus: undefined; |
|
drag: boolean; |
|
upload: FileData[] | FileData; |
|
clear: undefined; |
|
load: FileData[] | FileData; |
|
error: string; |
|
}>(); |
|
|
|
beforeUpdate(() => { |
|
can_scroll = el && el.offsetHeight + el.scrollTop > el.scrollHeight - 100; |
|
}); |
|
|
|
const scroll = (): void => { |
|
if (can_scroll && autoscroll && !user_has_scrolled_up) { |
|
el.scrollTo(0, el.scrollHeight); |
|
} |
|
}; |
|
|
|
async function handle_change(): Promise<void> { |
|
dispatch("change", value); |
|
if (!value_is_output) { |
|
dispatch("input"); |
|
} |
|
} |
|
|
|
afterUpdate(() => { |
|
if (autofocus && el !== null) { |
|
el.focus(); |
|
} |
|
if (can_scroll && autoscroll) { |
|
scroll(); |
|
} |
|
value_is_output = false; |
|
}); |
|
|
|
function handle_select(event: Event): void { |
|
const target: HTMLTextAreaElement | HTMLInputElement = event.target as |
|
| HTMLTextAreaElement |
|
| HTMLInputElement; |
|
const text = target.value; |
|
const index: [number, number] = [ |
|
target.selectionStart as number, |
|
target.selectionEnd as number |
|
]; |
|
dispatch("select", { value: text.substring(...index), index: index }); |
|
} |
|
|
|
async function handle_keypress(e: KeyboardEvent): Promise<void> { |
|
await tick(); |
|
if (e.key === "Enter" && e.shiftKey && lines > 1) { |
|
e.preventDefault(); |
|
if (can_submit) { |
|
dispatch("submit"); |
|
} |
|
} else if ( |
|
e.key === "Enter" && |
|
!e.shiftKey && |
|
lines === 1 && |
|
max_lines >= 1 |
|
) { |
|
e.preventDefault(); |
|
if (can_submit) { |
|
dispatch("submit"); |
|
} |
|
} |
|
} |
|
|
|
function handle_scroll(event: Event): void { |
|
const target = event.target as HTMLElement; |
|
const current_scroll_top = target.scrollTop; |
|
if (current_scroll_top < previous_scroll_top) { |
|
user_has_scrolled_up = true; |
|
} |
|
previous_scroll_top = current_scroll_top; |
|
|
|
const max_scroll_top = target.scrollHeight - target.clientHeight; |
|
const user_has_scrolled_to_bottom = current_scroll_top >= max_scroll_top; |
|
if (user_has_scrolled_to_bottom) { |
|
user_has_scrolled_up = false; |
|
} |
|
} |
|
|
|
async function handle_upload({ |
|
detail |
|
}: CustomEvent<FileData | FileData[]>): Promise<void> { |
|
handle_change(); |
|
if (Array.isArray(detail)) { |
|
for (let file of detail) { |
|
value.files.push(file); |
|
} |
|
value = value; |
|
} else { |
|
value.files.push(detail); |
|
value = value; |
|
} |
|
await tick(); |
|
dispatch("change", value); |
|
dispatch("upload", detail); |
|
} |
|
|
|
function remove_thumbnail(event: MouseEvent, index: number): void { |
|
handle_change(); |
|
event.stopPropagation(); |
|
value.files.splice(index, 1); |
|
value = value; |
|
} |
|
|
|
function handle_upload_click(): void { |
|
if (hidden_upload) { |
|
hidden_upload.value = ""; |
|
hidden_upload.click(); |
|
} |
|
} |
|
|
|
function handle_stop(): void { |
|
dispatch("stop"); |
|
} |
|
|
|
function handle_submit(): void { |
|
dispatch("submit"); |
|
} |
|
|
|
function handle_paste(event: ClipboardEvent): void { |
|
if (!event.clipboardData) return; |
|
const items = event.clipboardData.items; |
|
for (let index in items) { |
|
const item = items[index]; |
|
if (item.kind === "file" && item.type.includes("image")) { |
|
const blob = item.getAsFile(); |
|
if (blob) upload_component.load_files([blob]); |
|
} |
|
} |
|
} |
|
|
|
function handle_dragenter(event: DragEvent): void { |
|
event.preventDefault(); |
|
dragging = true; |
|
} |
|
|
|
function handle_dragleave(event: DragEvent): void { |
|
event.preventDefault(); |
|
const rect = full_container.getBoundingClientRect(); |
|
const { clientX, clientY } = event; |
|
if ( |
|
clientX <= rect.left || |
|
clientX >= rect.right || |
|
clientY <= rect.top || |
|
clientY >= rect.bottom |
|
) { |
|
dragging = false; |
|
} |
|
} |
|
|
|
function handle_drop(event: DragEvent): void { |
|
event.preventDefault(); |
|
dragging = false; |
|
if (event.dataTransfer && event.dataTransfer.files) { |
|
upload_component.load_files(Array.from(event.dataTransfer.files)); |
|
} |
|
} |
|
</script> |
|
|
|
<div |
|
class="full-container" |
|
class:dragging |
|
bind:this={full_container} |
|
on:dragenter={handle_dragenter} |
|
on:dragleave={handle_dragleave} |
|
on:dragover|preventDefault |
|
on:drop={handle_drop} |
|
role="group" |
|
aria-label="Multimedia input field" |
|
> |
|
<!-- svelte-ignore a11y-autofocus --> |
|
<label class:container> |
|
<BlockTitle {root} {show_label} {info}>{label}</BlockTitle> |
|
{#if value.files.length > 0 || uploading} |
|
<div |
|
class="thumbnails scroll-hide" |
|
aria-label="Uploaded files" |
|
data-testid="container_el" |
|
style="display: {value.files.length > 0 || uploading |
|
? 'flex' |
|
: 'none'};" |
|
> |
|
{#each value.files as file, index} |
|
<span role="listitem" aria-label="File thumbnail"> |
|
<button class="thumbnail-item thumbnail-small"> |
|
<button |
|
class:disabled |
|
class="delete-button" |
|
on:click={(event) => remove_thumbnail(event, index)} |
|
><Clear /></button |
|
> |
|
{#if file.mime_type && file.mime_type.includes("image")} |
|
<Image |
|
src={file.url} |
|
title={null} |
|
alt="" |
|
loading="lazy" |
|
class={"thumbnail-image"} |
|
/> |
|
{:else if file.mime_type && file.mime_type.includes("audio")} |
|
<Music /> |
|
{:else if file.mime_type && file.mime_type.includes("video")} |
|
<Video /> |
|
{:else} |
|
<File /> |
|
{/if} |
|
</button> |
|
</span> |
|
{/each} |
|
{#if uploading} |
|
<div class="loader" role="status" aria-label="Uploading"></div> |
|
{/if} |
|
</div> |
|
{/if} |
|
<div class="input-container"> |
|
<Upload |
|
bind:this={upload_component} |
|
on:load={handle_upload} |
|
{file_count} |
|
filetype={file_types} |
|
{root} |
|
{max_file_size} |
|
bind:dragging |
|
bind:uploading |
|
show_progress={false} |
|
disable_click={true} |
|
bind:hidden_upload |
|
on:error |
|
hidden={true} |
|
{upload} |
|
{stream_handler} |
|
></Upload> |
|
<button |
|
data-testid="upload-button" |
|
class="upload-button" |
|
on:click={handle_upload_click}><Paperclip /></button |
|
> |
|
<textarea |
|
data-testid="textbox" |
|
use:text_area_resize={{ |
|
text: value.text, |
|
lines: lines, |
|
max_lines: max_lines |
|
}} |
|
class="scroll-hide" |
|
class:no-label={!show_label} |
|
dir={rtl ? "rtl" : "ltr"} |
|
bind:value={value.text} |
|
bind:this={el} |
|
{placeholder} |
|
rows={lines} |
|
{disabled} |
|
{autofocus} |
|
on:keypress={handle_keypress} |
|
on:blur |
|
on:select={handle_select} |
|
on:focus |
|
on:scroll={handle_scroll} |
|
on:paste={handle_paste} |
|
style={text_align ? "text-align: " + text_align : ""} |
|
/> |
|
{#if submit_btn} |
|
<button |
|
class="submit-button" |
|
class:padded-button={submit_btn !== true} |
|
on:click={handle_submit} |
|
disabled={!can_submit} |
|
> |
|
{#if submit_btn === true} |
|
<Send /> |
|
{:else} |
|
{submit_btn} |
|
{/if} |
|
</button> |
|
{/if} |
|
{#if stop_btn} |
|
<button |
|
class="stop-button" |
|
class:padded-button={stop_btn !== true} |
|
on:click={handle_stop} |
|
> |
|
{#if stop_btn === true} |
|
<Square fill={"none"} stroke_width={2.5} /> |
|
{:else} |
|
{stop_btn} |
|
{/if} |
|
</button> |
|
{/if} |
|
</div> |
|
</label> |
|
</div> |
|
|
|
<style> |
|
.full-container { |
|
width: 100%; |
|
position: relative; |
|
} |
|
|
|
.full-container.dragging::after { |
|
content: ""; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
pointer-events: none; |
|
} |
|
|
|
.input-container { |
|
display: flex; |
|
position: relative; |
|
align-items: flex-end; |
|
} |
|
|
|
textarea { |
|
flex-grow: 1; |
|
outline: none !important; |
|
background: var(--block-background-fill); |
|
padding: var(--input-padding); |
|
color: var(--body-text-color); |
|
font-weight: var(--input-text-weight); |
|
font-size: var(--input-text-size); |
|
line-height: var(--line-sm); |
|
border: none; |
|
margin-top: 0px; |
|
margin-bottom: 0px; |
|
resize: none; |
|
position: relative; |
|
z-index: 1; |
|
} |
|
textarea.no-label { |
|
padding-top: 5px; |
|
padding-bottom: 5px; |
|
} |
|
|
|
textarea:disabled { |
|
-webkit-opacity: 1; |
|
opacity: 1; |
|
} |
|
|
|
textarea::placeholder { |
|
color: var(--input-placeholder-color); |
|
} |
|
|
|
.upload-button, |
|
.submit-button, |
|
.stop-button { |
|
border: none; |
|
text-align: center; |
|
text-decoration: none; |
|
font-size: 14px; |
|
cursor: pointer; |
|
border-radius: 15px; |
|
min-width: 30px; |
|
height: 30px; |
|
flex-shrink: 0; |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
z-index: var(--layer-1); |
|
} |
|
.padded-button { |
|
padding: 0 10px; |
|
} |
|
|
|
.stop-button, |
|
.upload-button, |
|
.submit-button { |
|
background: var(--button-secondary-background-fill); |
|
} |
|
|
|
.stop-button:hover, |
|
.upload-button:hover, |
|
.submit-button:hover { |
|
background: var(--button-secondary-background-fill-hover); |
|
} |
|
|
|
.stop-button:disabled, |
|
.upload-button:disabled, |
|
.submit-button:disabled { |
|
background: var(--button-secondary-background-fill); |
|
cursor: initial; |
|
} |
|
|
|
.submit-button :global(svg) { |
|
height: 22px; |
|
width: 22px; |
|
} |
|
.upload-button :global(svg) { |
|
height: 17px; |
|
width: 17px; |
|
} |
|
|
|
.stop-button :global(svg) { |
|
height: 16px; |
|
width: 16px; |
|
} |
|
|
|
.loader { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
--ring-color: transparent; |
|
position: relative; |
|
border: 5px solid #f3f3f3; |
|
border-top: 5px solid var(--color-accent); |
|
border-radius: 50%; |
|
width: 25px; |
|
height: 25px; |
|
animation: spin 2s linear infinite; |
|
} |
|
|
|
@keyframes spin { |
|
0% { |
|
transform: rotate(0deg); |
|
} |
|
100% { |
|
transform: rotate(360deg); |
|
} |
|
} |
|
|
|
.thumbnails :global(img) { |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
object-fit: cover; |
|
border-radius: var(--radius-lg); |
|
} |
|
|
|
.thumbnails { |
|
display: flex; |
|
align-items: center; |
|
gap: var(--spacing-lg); |
|
overflow-x: scroll; |
|
padding-top: var(--spacing-sm); |
|
margin-bottom: 6px; |
|
} |
|
|
|
.thumbnail-item { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
--ring-color: transparent; |
|
position: relative; |
|
box-shadow: |
|
0 0 0 2px var(--ring-color), |
|
var(--shadow-drop); |
|
border: 1px solid var(--border-color-primary); |
|
border-radius: var(--radius-lg); |
|
background: var(--background-fill-secondary); |
|
aspect-ratio: var(--ratio-square); |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
cursor: default; |
|
} |
|
|
|
.thumbnail-small { |
|
flex: none; |
|
transform: scale(0.9); |
|
transition: 0.075s; |
|
width: var(--size-12); |
|
height: var(--size-12); |
|
} |
|
|
|
.thumbnail-item :global(svg) { |
|
width: 30px; |
|
height: 30px; |
|
} |
|
|
|
.delete-button { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
position: absolute; |
|
right: -7px; |
|
top: -7px; |
|
color: var(--button-secondary-text-color); |
|
background: var(--button-secondary-background-fill); |
|
border: none; |
|
text-align: center; |
|
text-decoration: none; |
|
font-size: 10px; |
|
cursor: pointer; |
|
border-radius: 50%; |
|
width: 20px; |
|
height: 20px; |
|
} |
|
|
|
.disabled { |
|
display: none; |
|
} |
|
|
|
.delete-button :global(svg) { |
|
width: 12px; |
|
height: 12px; |
|
} |
|
|
|
.delete-button:hover { |
|
filter: brightness(1.2); |
|
border: 0.8px solid var(--color-grey-500); |
|
} |
|
</style> |
|
|