|
<script lang="ts"> |
|
import { |
|
beforeUpdate, |
|
afterUpdate, |
|
createEventDispatcher, |
|
tick |
|
} from "svelte"; |
|
import { BlockTitle } from "@gradio/atoms"; |
|
import { Copy, Check, Send, Square } from "@gradio/icons"; |
|
import { fade } from "svelte/transition"; |
|
import type { SelectData, CopyData } from "@gradio/utils"; |
|
|
|
export let value = ""; |
|
export let value_is_output = false; |
|
export let lines = 1; |
|
export let placeholder = "Type here..."; |
|
export let label: string; |
|
export let info: string | undefined = undefined; |
|
export let disabled = false; |
|
export let show_label = true; |
|
export let container = true; |
|
export let max_lines: number | undefined = undefined; |
|
export let type: "text" | "password" | "email" = "text"; |
|
export let show_copy_button = false; |
|
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 max_length: number | undefined = undefined; |
|
|
|
let el: HTMLTextAreaElement | HTMLInputElement; |
|
let copied = false; |
|
let timer: NodeJS.Timeout; |
|
let can_scroll: boolean; |
|
let previous_scroll_top = 0; |
|
let user_has_scrolled_up = false; |
|
let _max_lines: number; |
|
|
|
const show_textbox_border = !submit_btn; |
|
|
|
$: if (max_lines === undefined) { |
|
if (type === "text") { |
|
_max_lines = Math.max(lines, 20); |
|
} else { |
|
_max_lines = 1; |
|
} |
|
} else { |
|
_max_lines = Math.max(max_lines, lines); |
|
} |
|
|
|
$: value, el && lines !== _max_lines && resize({ target: el }); |
|
|
|
$: if (value === null) value = ""; |
|
|
|
const dispatch = createEventDispatcher<{ |
|
change: string; |
|
submit: undefined; |
|
stop: undefined; |
|
blur: undefined; |
|
select: SelectData; |
|
input: undefined; |
|
focus: undefined; |
|
copy: CopyData; |
|
}>(); |
|
|
|
beforeUpdate(() => { |
|
if ( |
|
!user_has_scrolled_up && |
|
el && |
|
el.offsetHeight + el.scrollTop > el.scrollHeight - 100 |
|
) { |
|
can_scroll = true; |
|
} |
|
}); |
|
|
|
const scroll = (): void => { |
|
if (can_scroll && autoscroll && !user_has_scrolled_up) { |
|
el.scrollTo(0, el.scrollHeight); |
|
} |
|
}; |
|
|
|
function handle_change(): void { |
|
dispatch("change", value); |
|
if (!value_is_output) { |
|
dispatch("input"); |
|
} |
|
} |
|
afterUpdate(() => { |
|
if (autofocus) { |
|
el.focus(); |
|
} |
|
if (can_scroll && autoscroll) { |
|
scroll(); |
|
} |
|
value_is_output = false; |
|
}); |
|
$: value, handle_change(); |
|
|
|
async function handle_copy(): Promise<void> { |
|
if ("clipboard" in navigator) { |
|
await navigator.clipboard.writeText(value); |
|
dispatch("copy", { value: value }); |
|
copy_feedback(); |
|
} |
|
} |
|
|
|
function copy_feedback(): void { |
|
copied = true; |
|
if (timer) clearTimeout(timer); |
|
timer = setTimeout(() => { |
|
copied = false; |
|
}, 1000); |
|
} |
|
|
|
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(); |
|
dispatch("submit"); |
|
} else if ( |
|
e.key === "Enter" && |
|
!e.shiftKey && |
|
lines === 1 && |
|
_max_lines >= 1 |
|
) { |
|
e.preventDefault(); |
|
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; |
|
} |
|
} |
|
|
|
function handle_stop(): void { |
|
dispatch("stop"); |
|
} |
|
|
|
function handle_submit(): void { |
|
dispatch("submit"); |
|
} |
|
|
|
async function resize( |
|
event: Event | { target: HTMLTextAreaElement | HTMLInputElement } |
|
): Promise<void> { |
|
await tick(); |
|
if (lines === _max_lines) return; |
|
|
|
const target = event.target as HTMLTextAreaElement; |
|
const computed_styles = window.getComputedStyle(target); |
|
const padding_top = parseFloat(computed_styles.paddingTop); |
|
const padding_bottom = parseFloat(computed_styles.paddingBottom); |
|
const line_height = parseFloat(computed_styles.lineHeight); |
|
|
|
let max = |
|
_max_lines === undefined |
|
? false |
|
: padding_top + padding_bottom + line_height * _max_lines; |
|
let min = padding_top + padding_bottom + lines * line_height; |
|
|
|
target.style.height = "1px"; |
|
|
|
let scroll_height; |
|
if (max && target.scrollHeight > max) { |
|
scroll_height = max; |
|
} else if (target.scrollHeight < min) { |
|
scroll_height = min; |
|
} else { |
|
scroll_height = target.scrollHeight; |
|
} |
|
|
|
target.style.height = `${scroll_height}px`; |
|
} |
|
|
|
function text_area_resize( |
|
_el: HTMLTextAreaElement, |
|
_value: string |
|
): any | undefined { |
|
if (lines === _max_lines) return; |
|
_el.style.overflowY = "scroll"; |
|
_el.addEventListener("input", resize); |
|
|
|
if (!_value.trim()) return; |
|
resize({ target: _el }); |
|
|
|
return { |
|
destroy: () => _el.removeEventListener("input", resize) |
|
}; |
|
} |
|
</script> |
|
|
|
<!-- svelte-ignore a11y-autofocus --> |
|
<label class:container class:show_textbox_border> |
|
{#if show_label && show_copy_button} |
|
{#if copied} |
|
<button |
|
in:fade={{ duration: 300 }} |
|
class="copy-button" |
|
aria-label="Copied" |
|
aria-roledescription="Text copied"><Check /></button |
|
> |
|
{:else} |
|
<button |
|
on:click={handle_copy} |
|
class="copy-button" |
|
aria-label="Copy" |
|
aria-roledescription="Copy text"><Copy /></button |
|
> |
|
{/if} |
|
{/if} |
|
<BlockTitle {show_label} {info}>{label}</BlockTitle> |
|
|
|
<div class="input-container"> |
|
{#if lines === 1 && _max_lines === 1} |
|
{#if type === "text"} |
|
<input |
|
data-testid="textbox" |
|
type="text" |
|
class="scroll-hide" |
|
dir={rtl ? "rtl" : "ltr"} |
|
bind:value |
|
bind:this={el} |
|
{placeholder} |
|
{disabled} |
|
{autofocus} |
|
maxlength={max_length} |
|
on:keypress={handle_keypress} |
|
on:blur |
|
on:select={handle_select} |
|
on:focus |
|
style={text_align ? "text-align: " + text_align : ""} |
|
/> |
|
{:else if type === "password"} |
|
<input |
|
data-testid="password" |
|
type="password" |
|
class="scroll-hide" |
|
bind:value |
|
bind:this={el} |
|
{placeholder} |
|
{disabled} |
|
{autofocus} |
|
maxlength={max_length} |
|
on:keypress={handle_keypress} |
|
on:blur |
|
on:select={handle_select} |
|
on:focus |
|
autocomplete="" |
|
/> |
|
{:else if type === "email"} |
|
<input |
|
data-testid="textbox" |
|
type="email" |
|
class="scroll-hide" |
|
bind:value |
|
bind:this={el} |
|
{placeholder} |
|
{disabled} |
|
{autofocus} |
|
maxlength={max_length} |
|
on:keypress={handle_keypress} |
|
on:blur |
|
on:select={handle_select} |
|
on:focus |
|
autocomplete="email" |
|
/> |
|
{/if} |
|
{:else} |
|
<textarea |
|
data-testid="textbox" |
|
use:text_area_resize={value} |
|
class="scroll-hide" |
|
dir={rtl ? "rtl" : "ltr"} |
|
class:no-label={!show_label && (submit_btn || stop_btn)} |
|
bind:value |
|
bind:this={el} |
|
{placeholder} |
|
rows={lines} |
|
{disabled} |
|
{autofocus} |
|
maxlength={max_length} |
|
on:keypress={handle_keypress} |
|
on:blur |
|
on:select={handle_select} |
|
on:focus |
|
on:scroll={handle_scroll} |
|
style={text_align ? "text-align: " + text_align : ""} |
|
/> |
|
{/if} |
|
{#if submit_btn} |
|
<button |
|
class="submit-button" |
|
class:padded-button={submit_btn !== true} |
|
on:click={handle_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> |
|
|
|
<style> |
|
label { |
|
display: block; |
|
width: 100%; |
|
} |
|
|
|
input, |
|
textarea { |
|
flex-grow: 1; |
|
outline: none !important; |
|
margin-top: 0px; |
|
margin-bottom: 0px; |
|
resize: none; |
|
z-index: 1; |
|
display: block; |
|
position: relative; |
|
outline: none !important; |
|
background: var(--input-background-fill); |
|
padding: var(--input-padding); |
|
width: 100%; |
|
color: var(--body-text-color); |
|
font-weight: var(--input-text-weight); |
|
font-size: var(--input-text-size); |
|
line-height: var(--line-sm); |
|
border: none; |
|
} |
|
textarea.no-label { |
|
padding-top: 5px; |
|
padding-bottom: 5px; |
|
} |
|
label.show_textbox_border input, |
|
label.show_textbox_border textarea { |
|
box-shadow: var(--input-shadow); |
|
} |
|
label:not(.container), |
|
label:not(.container) input, |
|
label:not(.container) textarea { |
|
height: 100%; |
|
} |
|
label.container.show_textbox_border input, |
|
label.container.show_textbox_border textarea { |
|
border: var(--input-border-width) solid var(--input-border-color); |
|
border-radius: var(--input-radius); |
|
} |
|
input:disabled, |
|
textarea:disabled { |
|
-webkit-opacity: 1; |
|
opacity: 1; |
|
} |
|
|
|
label.container.show_textbox_border input:focus, |
|
label.container.show_textbox_border textarea:focus { |
|
box-shadow: var(--input-shadow-focus); |
|
border-color: var(--input-border-color-focus); |
|
background: var(--input-background-fill-focus); |
|
} |
|
|
|
input::placeholder, |
|
textarea::placeholder { |
|
color: var(--input-placeholder-color); |
|
} |
|
|
|
.copy-button { |
|
display: flex; |
|
position: absolute; |
|
top: var(--block-label-margin); |
|
right: var(--block-label-margin); |
|
align-items: center; |
|
box-shadow: var(--shadow-drop); |
|
border: 1px solid var(--border-color-primary); |
|
border-top: none; |
|
border-right: none; |
|
border-radius: var(--block-label-right-radius); |
|
background: var(--block-label-background-fill); |
|
padding: 5px; |
|
width: 22px; |
|
height: 22px; |
|
overflow: hidden; |
|
color: var(--block-label-color); |
|
font: var(--font-sans); |
|
font-size: var(--button-small-text-size); |
|
} |
|
|
|
/* Same submit button style as MultimodalTextbox for the consistent UI */ |
|
.input-container { |
|
display: flex; |
|
position: relative; |
|
align-items: flex-end; |
|
} |
|
.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); |
|
} |
|
.stop-button, |
|
.submit-button { |
|
background: var(--button-secondary-background-fill); |
|
color: var(--button-secondary-text-color); |
|
} |
|
.stop-button:hover, |
|
.submit-button:hover { |
|
background: var(--button-secondary-background-fill-hover); |
|
} |
|
.stop-button:disabled, |
|
.submit-button:disabled { |
|
background: var(--button-secondary-background-fill); |
|
cursor: pointer; |
|
} |
|
.stop-button:active, |
|
.submit-button:active { |
|
box-shadow: var(--button-shadow-active); |
|
} |
|
.submit-button :global(svg) { |
|
height: 22px; |
|
width: 22px; |
|
} |
|
|
|
.stop-button :global(svg) { |
|
height: 16px; |
|
width: 16px; |
|
} |
|
.padded-button { |
|
padding: 0 10px; |
|
} |
|
</style> |
|
|