@gradio/multimodaltextbox 0.10.22-dev.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,247 +1,338 @@
1
- <script>import {
2
- onMount,
3
- beforeUpdate,
4
- afterUpdate,
5
- createEventDispatcher,
6
- tick
7
- } from "svelte";
8
- import { text_area_resize, resize } from "../shared/utils";
9
- import { BlockTitle } from "@gradio/atoms";
10
- import { Upload } from "@gradio/upload";
11
- import { Image } from "@gradio/image/shared";
12
- import {
13
- Clear,
14
- File,
15
- Music,
16
- Paperclip,
17
- Video,
18
- Send,
19
- Square,
20
- Microphone
21
- } from "@gradio/icons";
22
- import InteractiveAudio from "../../audio/interactive/InteractiveAudio.svelte";
23
- export let value = {
24
- text: "",
25
- files: []
26
- };
27
- export let value_is_output = false;
28
- export let lines = 1;
29
- export let i18n;
30
- export let placeholder = "Type here...";
31
- export let disabled = false;
32
- export let label;
33
- export let info = void 0;
34
- export let show_label = true;
35
- export let max_lines;
36
- export let submit_btn = null;
37
- export let stop_btn = null;
38
- export let rtl = false;
39
- export let autofocus = false;
40
- export let text_align = void 0;
41
- export let autoscroll = true;
42
- export let root;
43
- export let file_types_string = null;
44
- export let max_file_size = null;
45
- export let upload;
46
- export let stream_handler;
47
- export let file_count = "multiple";
48
- export let max_plain_text_length = 1e3;
49
- export let waveform_settings;
50
- export let waveform_options = {
51
- show_recording_waveform: true
52
- };
53
- export let sources_string = "upload";
54
- export let active_source = null;
55
- export let html_attributes = null;
56
- let upload_component;
57
- let el;
58
- let can_scroll;
59
- let previous_scroll_top = 0;
60
- let user_has_scrolled_up = false;
61
- export let dragging = false;
62
- let uploading = false;
63
- let oldValue = value?.text ?? "";
64
- let recording = false;
65
- $: sources = sources_string.split(",").map((s) => s.trim()).filter((s) => s === "upload" || s === "microphone");
66
- $: file_types = file_types_string ? file_types_string.split(",").map((s) => s.trim()) : null;
67
- $: dispatch("drag", dragging);
68
- let mic_audio = null;
69
- let full_container;
70
- $: if (oldValue !== value.text) {
71
- dispatch("change", value);
72
- oldValue = value.text;
73
- }
74
- $: if (value === null) value = { text: "", files: [] };
75
- $: value, el && lines !== max_lines && resize(el, lines, max_lines);
76
- const dispatch = createEventDispatcher();
77
- beforeUpdate(() => {
78
- can_scroll = el && el.offsetHeight + el.scrollTop > el.scrollHeight - 100;
79
- });
80
- const scroll = () => {
81
- if (can_scroll && autoscroll && !user_has_scrolled_up) {
82
- el.scrollTo(0, el.scrollHeight);
83
- }
84
- };
85
- async function handle_change() {
86
- dispatch("change", value);
87
- if (!value_is_output) {
88
- dispatch("input");
89
- }
90
- }
91
- onMount(() => {
92
- if (autofocus && el !== null) {
93
- el.focus();
94
- }
95
- });
96
- const after_update = () => {
97
- if (can_scroll && autoscroll) {
98
- scroll();
99
- }
100
- if (autofocus && el) {
101
- el.focus();
102
- }
103
- };
104
- afterUpdate(after_update);
105
- function handle_select(event) {
106
- const target = event.target;
107
- const text = target.value;
108
- const index = [
109
- target.selectionStart,
110
- target.selectionEnd
111
- ];
112
- dispatch("select", { value: text.substring(...index), index });
113
- }
114
- async function handle_keypress(e) {
115
- await tick();
116
- if (e.key === "Enter" && e.shiftKey && lines > 1) {
117
- e.preventDefault();
118
- dispatch("submit");
119
- } else if (e.key === "Enter" && !e.shiftKey && lines === 1 && max_lines >= 1) {
120
- e.preventDefault();
121
- dispatch("submit");
122
- active_source = null;
123
- if (mic_audio) {
124
- value.files.push(mic_audio);
125
- value = value;
126
- mic_audio = null;
127
- }
128
- }
129
- }
130
- function handle_scroll(event) {
131
- const target = event.target;
132
- const current_scroll_top = target.scrollTop;
133
- if (current_scroll_top < previous_scroll_top) {
134
- user_has_scrolled_up = true;
135
- }
136
- previous_scroll_top = current_scroll_top;
137
- const max_scroll_top = target.scrollHeight - target.clientHeight;
138
- const user_has_scrolled_to_bottom = current_scroll_top >= max_scroll_top;
139
- if (user_has_scrolled_to_bottom) {
140
- user_has_scrolled_up = false;
141
- }
142
- }
143
- async function handle_upload({
144
- detail
145
- }) {
146
- handle_change();
147
- if (Array.isArray(detail)) {
148
- for (let file of detail) {
149
- value.files.push(file);
150
- }
151
- value = value;
152
- } else {
153
- value.files.push(detail);
154
- value = value;
155
- }
156
- await tick();
157
- dispatch("change", value);
158
- dispatch("upload", detail);
159
- }
160
- function remove_thumbnail(event, index) {
161
- handle_change();
162
- event.stopPropagation();
163
- value.files.splice(index, 1);
164
- value = value;
165
- }
166
- function handle_upload_click() {
167
- upload_component.open_upload();
168
- }
169
- function handle_stop() {
170
- dispatch("stop");
171
- }
172
- function handle_submit() {
173
- dispatch("submit");
174
- active_source = null;
175
- if (mic_audio) {
176
- value.files.push(mic_audio);
177
- value = value;
178
- mic_audio = null;
179
- }
180
- }
181
- async function handle_paste(event) {
182
- if (!event.clipboardData) return;
183
- const items = event.clipboardData.items;
184
- const text = event.clipboardData.getData("text");
185
- if (text && text.length > max_plain_text_length) {
186
- event.preventDefault();
187
- const file = new window.File([text], "pasted_text.txt", {
188
- type: "text/plain",
189
- lastModified: Date.now()
190
- });
191
- if (upload_component) {
192
- upload_component.load_files([file]);
193
- }
194
- return;
195
- }
196
- for (let index in items) {
197
- const item = items[index];
198
- if (item.kind === "file" && item.type.includes("image")) {
199
- const blob = item.getAsFile();
200
- if (blob) upload_component.load_files([blob]);
201
- }
202
- }
203
- }
204
- function handle_dragenter(event) {
205
- event.preventDefault();
206
- dragging = true;
207
- }
208
- function handle_dragleave(event) {
209
- event.preventDefault();
210
- const rect = full_container.getBoundingClientRect();
211
- const { clientX, clientY } = event;
212
- if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
213
- dragging = false;
214
- }
215
- }
216
- function handle_drop(event) {
217
- event.preventDefault();
218
- dragging = false;
219
- if (event.dataTransfer && event.dataTransfer.files) {
220
- const files = Array.from(event.dataTransfer.files);
221
- if (file_types) {
222
- const valid_files = files.filter((file) => {
223
- return file_types.some((type) => {
224
- if (type.startsWith(".")) {
225
- return file.name.toLowerCase().endsWith(type.toLowerCase());
226
- }
227
- return file.type.match(new RegExp(type.replace("*", ".*")));
228
- });
229
- });
230
- const invalid_files = files.length - valid_files.length;
231
- if (invalid_files > 0) {
232
- dispatch(
233
- "error",
234
- `${invalid_files} file(s) were rejected. Accepted formats: ${file_types.join(", ")}`
235
- );
236
- }
237
- if (valid_files.length > 0) {
238
- upload_component.load_files(valid_files);
239
- }
240
- } else {
241
- upload_component.load_files(files);
242
- }
243
- }
244
- }
1
+ <script lang="ts">
2
+ import {
3
+ onMount,
4
+ beforeUpdate,
5
+ afterUpdate,
6
+ createEventDispatcher,
7
+ tick
8
+ } from "svelte";
9
+ import { text_area_resize, resize } from "../shared/utils";
10
+ import { BlockTitle } from "@gradio/atoms";
11
+ import { Upload } from "@gradio/upload";
12
+ import { Image } from "@gradio/image/shared";
13
+ import type { I18nFormatter } from "js/core/src/gradio_helper";
14
+ import type { FileData, Client } from "@gradio/client";
15
+ import type { WaveformOptions } from "@gradio/audio";
16
+ import {
17
+ Clear,
18
+ File,
19
+ Music,
20
+ Paperclip,
21
+ Video,
22
+ ArrowUp,
23
+ Square,
24
+ Microphone,
25
+ Check
26
+ } from "@gradio/icons";
27
+ import type { SelectData } from "@gradio/utils";
28
+ import { BaseInteractiveAudio as InteractiveAudio } from "@gradio/audio";
29
+ import {
30
+ MinimalAudioPlayer,
31
+ MinimalAudioRecorder
32
+ } from "@gradio/audio/shared";
33
+ import type { InputHTMLAttributes } from "./types";
34
+
35
+ export let value: { text: string; files: FileData[] } = {
36
+ text: "",
37
+ files: []
38
+ };
39
+
40
+ export let value_is_output = false;
41
+ export let lines = 1;
42
+ export let i18n: I18nFormatter;
43
+ export let placeholder = "";
44
+ export let disabled = false;
45
+ export let label: string;
46
+ export let info: string | undefined = undefined;
47
+ export let show_label = true;
48
+ export let max_lines: number;
49
+ export let submit_btn: string | boolean | null = null;
50
+ export let stop_btn: string | boolean | null = null;
51
+ export let rtl = false;
52
+ export let autofocus = false;
53
+ export let text_align: "left" | "right" | undefined = undefined;
54
+ export let autoscroll = true;
55
+ export let root: string;
56
+ export let file_types_string: string | null = null;
57
+ export let max_file_size: number | null = null;
58
+ export let upload: Client["upload"];
59
+ export let stream_handler: Client["stream"];
60
+ export let file_count: "single" | "multiple" | "directory" = "multiple";
61
+ export let max_plain_text_length = 1000;
62
+ export let waveform_settings: Record<string, any>;
63
+ export let waveform_options: WaveformOptions = {
64
+ show_recording_waveform: true
65
+ };
66
+ export let sources_string:
67
+ | "upload"
68
+ | "upload,microphone"
69
+ | "microphone"
70
+ | "microphone,upload" = "upload";
71
+ export let active_source: "microphone" | null = null;
72
+ export let html_attributes: InputHTMLAttributes | null = null;
73
+ export let upload_promise: Promise<any> | null = null;
74
+
75
+ let upload_component: Upload;
76
+ let el: HTMLTextAreaElement | HTMLInputElement;
77
+ let can_scroll: boolean;
78
+ let previous_scroll_top = 0;
79
+ let user_has_scrolled_up = false;
80
+ export let dragging = false;
81
+ let uploading = false;
82
+ // value can be null in multimodalchatinterface when loading a deep link
83
+ let oldValue = value?.text ?? "";
84
+ let recording = false;
85
+
86
+ $: sources = sources_string
87
+ .split(",")
88
+ .map((s) => s.trim())
89
+ .filter((s) => s === "upload" || s === "microphone") as (
90
+ | "upload"
91
+ | "microphone"
92
+ )[];
93
+ $: file_types = file_types_string
94
+ ? file_types_string.split(",").map((s) => s.trim())
95
+ : null;
96
+ $: dispatch("drag", dragging);
97
+ $: show_upload =
98
+ sources &&
99
+ sources.includes("upload") &&
100
+ !(file_count === "single" && value.files.length > 0);
101
+ let mic_audio: FileData | null = null;
102
+
103
+ let full_container: HTMLDivElement;
104
+
105
+ $: if (oldValue !== value.text) {
106
+ dispatch("change", value);
107
+ oldValue = value.text;
108
+ }
109
+
110
+ $: if (value === null) value = { text: "", files: [] };
111
+ $: (value, el && lines !== max_lines && resize(el, lines, max_lines));
112
+
113
+ const dispatch = createEventDispatcher<{
114
+ change: typeof value;
115
+ submit: undefined;
116
+ stop: undefined;
117
+ blur: undefined;
118
+ select: SelectData;
119
+ input: undefined;
120
+ focus: undefined;
121
+ drag: boolean;
122
+ upload: FileData[] | FileData;
123
+ clear: undefined;
124
+ load: FileData[] | FileData;
125
+ error: string;
126
+ start_recording: undefined;
127
+ pause_recording: undefined;
128
+ stop_recording: undefined;
129
+ }>();
130
+
131
+ beforeUpdate(() => {
132
+ can_scroll = el && el.offsetHeight + el.scrollTop > el.scrollHeight - 100;
133
+ });
134
+
135
+ const scroll = (): void => {
136
+ if (can_scroll && autoscroll && !user_has_scrolled_up) {
137
+ el.scrollTo(0, el.scrollHeight);
138
+ }
139
+ };
140
+
141
+ async function handle_change(): Promise<void> {
142
+ dispatch("change", value);
143
+ if (!value_is_output) {
144
+ dispatch("input");
145
+ }
146
+ }
147
+
148
+ onMount(() => {
149
+ if (autofocus && el !== null) {
150
+ el.focus();
151
+ }
152
+ });
153
+
154
+ const after_update = (): void => {
155
+ if (can_scroll && autoscroll) {
156
+ scroll();
157
+ }
158
+ if (autofocus && el) {
159
+ el.focus();
160
+ }
161
+ };
162
+
163
+ afterUpdate(after_update);
164
+
165
+ function handle_select(event: Event): void {
166
+ const target: HTMLTextAreaElement | HTMLInputElement = event.target as
167
+ | HTMLTextAreaElement
168
+ | HTMLInputElement;
169
+ const text = target.value;
170
+ const index: [number, number] = [
171
+ target.selectionStart as number,
172
+ target.selectionEnd as number
173
+ ];
174
+ dispatch("select", { value: text.substring(...index), index: index });
175
+ }
176
+
177
+ async function handle_keypress(e: KeyboardEvent): Promise<void> {
178
+ if (e.key === "Enter" && e.shiftKey && lines > 1) {
179
+ e.preventDefault();
180
+ await tick();
181
+ dispatch("submit");
182
+ } else if (
183
+ e.key === "Enter" &&
184
+ !e.shiftKey &&
185
+ lines === 1 &&
186
+ max_lines >= 1
187
+ ) {
188
+ e.preventDefault();
189
+ add_mic_audio_to_files();
190
+ active_source = null;
191
+ await tick();
192
+ dispatch("submit");
193
+ }
194
+ }
195
+
196
+ function handle_scroll(event: Event): void {
197
+ const target = event.target as HTMLElement;
198
+ const current_scroll_top = target.scrollTop;
199
+ if (current_scroll_top < previous_scroll_top) {
200
+ user_has_scrolled_up = true;
201
+ }
202
+ previous_scroll_top = current_scroll_top;
203
+
204
+ const max_scroll_top = target.scrollHeight - target.clientHeight;
205
+ const user_has_scrolled_to_bottom = current_scroll_top >= max_scroll_top;
206
+ if (user_has_scrolled_to_bottom) {
207
+ user_has_scrolled_up = false;
208
+ }
209
+ }
210
+
211
+ async function handle_upload({
212
+ detail
213
+ }: CustomEvent<FileData>): Promise<void> {
214
+ handle_change();
215
+ if (Array.isArray(detail)) {
216
+ for (let file of detail) {
217
+ value.files.push(file);
218
+ }
219
+ value = value;
220
+ } else {
221
+ value.files.push(detail);
222
+ value = value;
223
+ }
224
+ await tick();
225
+ dispatch("change", value);
226
+ dispatch("upload", detail);
227
+ }
228
+
229
+ function remove_thumbnail(event: MouseEvent, index: number): void {
230
+ handle_change();
231
+ event.stopPropagation();
232
+ value.files.splice(index, 1);
233
+ value = value;
234
+ }
235
+
236
+ function handle_upload_click(): void {
237
+ upload_component.open_upload();
238
+ }
239
+
240
+ function handle_stop(): void {
241
+ dispatch("stop");
242
+ }
243
+
244
+ function add_mic_audio_to_files(): void {
245
+ if (mic_audio) {
246
+ value.files.push(mic_audio);
247
+ value = value;
248
+ mic_audio = null;
249
+ dispatch("change", value);
250
+ }
251
+ }
252
+
253
+ function handle_submit(): void {
254
+ add_mic_audio_to_files();
255
+ active_source = null;
256
+ dispatch("submit");
257
+ }
258
+
259
+ async function handle_paste(event: ClipboardEvent): Promise<void> {
260
+ if (!event.clipboardData) return;
261
+ const items = event.clipboardData.items;
262
+ const text = event.clipboardData.getData("text");
263
+
264
+ if (text && text.length > max_plain_text_length) {
265
+ event.preventDefault();
266
+ const file = new window.File([text], "pasted_text.txt", {
267
+ type: "text/plain",
268
+ lastModified: Date.now()
269
+ });
270
+ if (upload_component) {
271
+ upload_component.load_files([file]);
272
+ }
273
+ return;
274
+ }
275
+
276
+ for (let index in items) {
277
+ const item = items[index];
278
+ if (item.kind === "file" && item.type.includes("image")) {
279
+ const blob = item.getAsFile();
280
+ if (blob) upload_component.load_files([blob]);
281
+ }
282
+ }
283
+ }
284
+
285
+ function handle_dragenter(event: DragEvent): void {
286
+ event.preventDefault();
287
+ dragging = true;
288
+ }
289
+
290
+ function handle_dragleave(event: DragEvent): void {
291
+ event.preventDefault();
292
+ const rect = full_container.getBoundingClientRect();
293
+ const { clientX, clientY } = event;
294
+ if (
295
+ clientX <= rect.left ||
296
+ clientX >= rect.right ||
297
+ clientY <= rect.top ||
298
+ clientY >= rect.bottom
299
+ ) {
300
+ dragging = false;
301
+ }
302
+ }
303
+
304
+ function handle_drop(event: DragEvent): void {
305
+ event.preventDefault();
306
+ dragging = false;
307
+ if (event.dataTransfer && event.dataTransfer.files) {
308
+ const files = Array.from(event.dataTransfer.files);
309
+
310
+ if (file_types) {
311
+ const valid_files = files.filter((file) => {
312
+ return file_types.some((type) => {
313
+ if (type.startsWith(".")) {
314
+ return file.name.toLowerCase().endsWith(type.toLowerCase());
315
+ }
316
+ return file.type.match(new RegExp(type.replace("*", ".*")));
317
+ });
318
+ });
319
+
320
+ const invalid_files = files.length - valid_files.length;
321
+ if (invalid_files > 0) {
322
+ dispatch(
323
+ "error",
324
+ `${invalid_files} file(s) were rejected. Accepted formats: ${file_types.join(", ")}`
325
+ );
326
+ }
327
+
328
+ if (valid_files.length > 0) {
329
+ upload_component.load_files(valid_files);
330
+ }
331
+ } else {
332
+ upload_component.load_files(files);
333
+ }
334
+ }
335
+ }
245
336
  </script>
246
337
 
247
338
  <div
@@ -256,80 +347,77 @@ function handle_drop(event) {
256
347
  aria-label="Multimedia input field"
257
348
  >
258
349
  <BlockTitle {show_label} {info} {rtl}>{label}</BlockTitle>
259
- {#if value.files.length > 0 || uploading}
260
- <div
261
- class="thumbnails scroll-hide"
262
- aria-label="Uploaded files"
263
- data-testid="container_el"
264
- style="display: {value.files.length > 0 || uploading ? 'flex' : 'none'};"
265
- >
266
- {#each value.files as file, index}
267
- <span role="listitem" aria-label="File thumbnail">
268
- <button class="thumbnail-item thumbnail-small">
269
- <button
270
- class:disabled
271
- class="delete-button"
272
- on:click={(event) => remove_thumbnail(event, index)}
273
- ><Clear /></button
274
- >
275
- {#if file.mime_type && file.mime_type.includes("image")}
276
- <Image
277
- src={file.url}
278
- title={null}
279
- alt=""
280
- loading="lazy"
281
- class={"thumbnail-image"}
282
- />
283
- {:else if file.mime_type && file.mime_type.includes("audio")}
284
- <Music />
285
- {:else if file.mime_type && file.mime_type.includes("video")}
286
- <Video />
287
- {:else}
288
- <File />
289
- {/if}
290
- </button>
291
- </span>
292
- {/each}
293
- {#if uploading}
294
- <div class="loader" role="status" aria-label="Uploading"></div>
295
- {/if}
296
- </div>
297
- {/if}
298
- {#if sources && sources.includes("microphone") && active_source === "microphone"}
299
- <InteractiveAudio
300
- on:change={({ detail }) => {
301
- if (detail !== null) {
302
- mic_audio = detail;
303
- }
304
- }}
305
- on:clear={() => {
306
- active_source = null;
307
- }}
308
- on:start_recording={() => dispatch("start_recording")}
309
- on:pause_recording={() => dispatch("pause_recording")}
310
- on:stop_recording={() => dispatch("stop_recording")}
311
- sources={["microphone"]}
312
- class_name="compact-audio"
313
- {recording}
314
- {waveform_settings}
315
- {waveform_options}
316
- {i18n}
317
- {active_source}
318
- {upload}
319
- {stream_handler}
320
- stream_every={1}
321
- editable={true}
322
- {label}
323
- {root}
324
- loop={false}
325
- show_label={false}
326
- buttons={[]}
327
- dragging={false}
328
- />
329
- {/if}
330
350
  <div class="input-container">
331
- {#if sources && sources.includes("upload") && !(file_count === "single" && value.files.length > 0)}
351
+ {#if sources && sources.includes("microphone") && active_source === "microphone"}
352
+ <div class="recording-overlay" class:has-audio={mic_audio !== null}>
353
+ {#if !mic_audio}
354
+ <div class="recording-content">
355
+ <MinimalAudioRecorder
356
+ label={label || "Audio"}
357
+ {waveform_settings}
358
+ {recording}
359
+ {upload}
360
+ {root}
361
+ {max_file_size}
362
+ bind:upload_promise
363
+ on:change={({ detail }) => {
364
+ mic_audio = detail;
365
+ }}
366
+ on:stop_recording={() => {
367
+ recording = false;
368
+ dispatch("stop_recording");
369
+ }}
370
+ on:clear={() => {
371
+ active_source = null;
372
+ recording = false;
373
+ mic_audio = null;
374
+ }}
375
+ on:error={({ detail }) => {
376
+ console.log("Failed to record:", detail);
377
+ active_source = null;
378
+ recording = false;
379
+ mic_audio = null;
380
+ }}
381
+ />
382
+ </div>
383
+ {:else}
384
+ <div class="recording-content">
385
+ <MinimalAudioPlayer
386
+ value={mic_audio}
387
+ label={label || "Audio"}
388
+ loop={false}
389
+ />
390
+ <div class="action-buttons">
391
+ <button
392
+ class="confirm-button"
393
+ on:click={() => {
394
+ add_mic_audio_to_files();
395
+ active_source = null;
396
+ recording = false;
397
+ }}
398
+ aria-label="Attach audio"
399
+ >
400
+ <Check />
401
+ </button>
402
+ <button
403
+ class="cancel-button"
404
+ on:click={() => {
405
+ active_source = null;
406
+ recording = false;
407
+ mic_audio = null;
408
+ }}
409
+ aria-label="Clear audio"
410
+ >
411
+ <Clear />
412
+ </button>
413
+ </div>
414
+ </div>
415
+ {/if}
416
+ </div>
417
+ {/if}
418
+ {#if show_upload}
332
419
  <Upload
420
+ bind:upload_promise
333
421
  bind:this={upload_component}
334
422
  on:load={handle_upload}
335
423
  {file_count}
@@ -345,87 +433,154 @@ function handle_drop(event) {
345
433
  {upload}
346
434
  {stream_handler}
347
435
  />
348
- <button
349
- data-testid="upload-button"
350
- class="upload-button"
351
- {disabled}
352
- on:click={disabled ? undefined : handle_upload_click}
353
- ><Paperclip /></button
354
- >
355
436
  {/if}
356
- {#if sources && sources.includes("microphone")}
357
- <button
358
- data-testid="microphone-button"
359
- class="microphone-button"
360
- class:recording
361
- {disabled}
362
- on:click={disabled
363
- ? undefined
364
- : () => {
365
- active_source =
366
- active_source !== "microphone" ? "microphone" : null;
437
+
438
+ <div
439
+ class="input-wrapper"
440
+ class:has-files={value.files.length > 0 || uploading}
441
+ >
442
+ {#if value.files.length > 0 || uploading || show_upload}
443
+ <div
444
+ class="thumbnails"
445
+ aria-label="Uploaded files"
446
+ data-testid="container_el"
447
+ >
448
+ {#if show_upload}
449
+ <button
450
+ data-testid="upload-button"
451
+ class="upload-button thumbnail-add"
452
+ {disabled}
453
+ on:click={handle_upload_click}
454
+ aria-label="Upload a file"
455
+ >
456
+ <Paperclip />
457
+ </button>
458
+ {/if}
459
+ {#each value.files as file, index}
460
+ <span
461
+ class="thumbnail-wrapper"
462
+ role="listitem"
463
+ aria-label="File thumbnail"
464
+ >
465
+ <div class="thumbnail-item thumbnail-small">
466
+ {#if file.mime_type && file.mime_type.includes("image")}
467
+ <Image
468
+ src={file.url}
469
+ restProps={{
470
+ title: null,
471
+ alt: "",
472
+ loading: "lazy",
473
+ class: "thumbnail-image"
474
+ }}
475
+ />
476
+ {:else if file.mime_type && file.mime_type.includes("audio")}
477
+ <Music />
478
+ {:else if file.mime_type && file.mime_type.includes("video")}
479
+ <Video />
480
+ {:else}
481
+ <File />
482
+ {/if}
483
+ <button
484
+ class="delete-button"
485
+ on:click={(event) => remove_thumbnail(event, index)}
486
+ aria-label="Remove file"
487
+ >
488
+ <Clear />
489
+ </button>
490
+ </div>
491
+ </span>
492
+ {/each}
493
+ {#if uploading}
494
+ <div class="loader" role="status" aria-label="Uploading"></div>
495
+ {/if}
496
+ </div>
497
+ {/if}
498
+
499
+ <div class="input-row">
500
+ <textarea
501
+ data-testid="textbox"
502
+ use:text_area_resize={{
503
+ text: value.text,
504
+ lines: lines,
505
+ max_lines: max_lines
506
+ }}
507
+ class:no-label={!show_label}
508
+ dir={rtl ? "rtl" : "ltr"}
509
+ bind:value={value.text}
510
+ bind:this={el}
511
+ {placeholder}
512
+ rows={lines}
513
+ {disabled}
514
+ on:keypress={handle_keypress}
515
+ on:blur
516
+ on:select={handle_select}
517
+ on:focus
518
+ on:scroll={handle_scroll}
519
+ on:paste={handle_paste}
520
+ style={text_align ? "text-align: " + text_align : ""}
521
+ autocapitalize={html_attributes?.autocapitalize}
522
+ autocorrect={html_attributes?.autocorrect}
523
+ spellcheck={html_attributes?.spellcheck}
524
+ autocomplete={html_attributes?.autocomplete}
525
+ tabindex={html_attributes?.tabindex}
526
+ enterkeyhint={html_attributes?.enterkeyhint}
527
+ lang={html_attributes?.lang}
528
+ />
529
+
530
+ {#if sources && sources.includes("microphone")}
531
+ <button
532
+ data-testid="microphone-button"
533
+ class="microphone-button"
534
+ class:recording
535
+ {disabled}
536
+ on:click={async () => {
537
+ if (active_source !== "microphone") {
538
+ active_source = "microphone";
539
+ await tick();
540
+ recording = true;
541
+ } else {
542
+ active_source = null;
543
+ recording = false;
544
+ }
367
545
  }}
368
- >
369
- <Microphone />
370
- </button>
371
- {/if}
372
- <textarea
373
- data-testid="textbox"
374
- use:text_area_resize={{
375
- text: value.text,
376
- lines: lines,
377
- max_lines: max_lines
378
- }}
379
- class="scroll-hide"
380
- class:no-label={!show_label}
381
- dir={rtl ? "rtl" : "ltr"}
382
- bind:value={value.text}
383
- bind:this={el}
384
- {placeholder}
385
- rows={lines}
386
- {disabled}
387
- on:keypress={handle_keypress}
388
- on:blur
389
- on:select={handle_select}
390
- on:focus
391
- on:scroll={handle_scroll}
392
- on:paste={handle_paste}
393
- style={text_align ? "text-align: " + text_align : ""}
394
- autocapitalize={html_attributes?.autocapitalize}
395
- autocorrect={html_attributes?.autocorrect}
396
- spellcheck={html_attributes?.spellcheck}
397
- autocomplete={html_attributes?.autocomplete}
398
- tabindex={html_attributes?.tabindex}
399
- enterkeyhint={html_attributes?.enterkeyhint}
400
- lang={html_attributes?.lang}
401
- />
402
- {#if submit_btn}
403
- <button
404
- class="submit-button"
405
- class:padded-button={submit_btn !== true}
406
- {disabled}
407
- on:click={disabled ? undefined : handle_submit}
408
- >
409
- {#if submit_btn === true}
410
- <Send />
411
- {:else}
412
- {submit_btn}
546
+ aria-label="Record audio"
547
+ >
548
+ <Microphone />
549
+ </button>
413
550
  {/if}
414
- </button>
415
- {/if}
416
- {#if stop_btn}
417
- <button
418
- class="stop-button"
419
- class:padded-button={stop_btn !== true}
420
- on:click={handle_stop}
421
- >
422
- {#if stop_btn === true}
423
- <Square fill={"none"} stroke_width={2.5} />
424
- {:else}
425
- {stop_btn}
551
+
552
+ {#if submit_btn}
553
+ <button
554
+ class="submit-button"
555
+ data-testid="submit-button"
556
+ class:padded-button={submit_btn !== true}
557
+ {disabled}
558
+ on:click={handle_submit}
559
+ aria-label="Submit"
560
+ >
561
+ {#if submit_btn === true}
562
+ <ArrowUp />
563
+ {:else}
564
+ {submit_btn}
565
+ {/if}
566
+ </button>
426
567
  {/if}
427
- </button>
428
- {/if}
568
+ {#if stop_btn}
569
+ <button
570
+ class="stop-button"
571
+ class:padded-button={stop_btn !== true}
572
+ on:click={handle_stop}
573
+ aria-label="Stop"
574
+ >
575
+ {#if stop_btn === true}
576
+ <Square fill={"none"} stroke_width={2.5} />
577
+ {:else}
578
+ {stop_btn}
579
+ {/if}
580
+ </button>
581
+ {/if}
582
+ </div>
583
+ </div>
429
584
  </div>
430
585
  </div>
431
586
 
@@ -438,237 +593,414 @@ function handle_drop(event) {
438
593
  }
439
594
 
440
595
  .full-container.dragging {
441
- border: 1px solid var(--color-accent);
442
- border-radius: calc(var(--radius-sm) - 1px);
596
+ border-color: var(--color-accent);
597
+ border-radius: calc(var(--radius-sm) - var(--size-px));
443
598
  }
444
599
 
445
600
  .full-container.dragging::after {
446
601
  content: "";
447
602
  position: absolute;
448
- top: 0;
449
- left: 0;
450
- right: 0;
451
- bottom: 0;
603
+ inset: 0;
452
604
  pointer-events: none;
453
605
  }
454
606
 
455
607
  .input-container {
456
608
  display: flex;
457
609
  position: relative;
610
+ flex-direction: column;
611
+ gap: 0;
612
+ }
613
+
614
+ .input-wrapper {
615
+ display: flex;
616
+ position: relative;
617
+ flex-direction: column;
618
+ gap: 0;
619
+ background: var(--block-background-fill);
620
+ border-radius: var(--radius-xl);
621
+ padding: var(--spacing-sm);
622
+ align-items: flex-start;
623
+ min-height: auto;
624
+ }
625
+
626
+ .input-wrapper.has-files {
627
+ padding-top: var(--spacing-xs);
628
+ }
629
+
630
+ .input-row {
631
+ display: flex;
458
632
  align-items: flex-end;
633
+ gap: var(--spacing-sm);
634
+ width: 100%;
635
+ }
636
+
637
+ .thumbnails {
638
+ display: flex;
639
+ align-items: center;
640
+ gap: var(--spacing-sm);
641
+ padding: var(--spacing-xs) 0;
642
+ margin-bottom: var(--spacing-xs);
643
+ overflow-x: auto;
644
+ overflow-y: hidden;
645
+ scrollbar-width: none;
646
+ -ms-overflow-style: none;
647
+ flex-wrap: nowrap;
648
+ -webkit-overflow-scrolling: touch;
649
+ scroll-behavior: smooth;
650
+ overflow-y: scroll;
651
+ width: 100%;
652
+ }
653
+
654
+ .thumbnails::-webkit-scrollbar,
655
+ .thumbnails::-webkit-scrollbar-track,
656
+ .thumbnails::-webkit-scrollbar-thumb {
657
+ display: none;
658
+ }
659
+
660
+ .thumbnails :global(img) {
661
+ width: var(--size-full);
662
+ height: var(--size-full);
663
+ object-fit: cover;
664
+ border-radius: var(--radius-md);
665
+ }
666
+
667
+ .thumbnail-wrapper {
668
+ position: relative;
669
+ flex-shrink: 0;
670
+ }
671
+
672
+ .thumbnail-item {
673
+ position: relative;
674
+ display: flex;
675
+ justify-content: center;
676
+ align-items: center;
677
+ border-radius: var(--radius-md);
678
+ background: var(--background-fill-secondary);
679
+ width: var(--size-full);
680
+ height: var(--size-full);
681
+ cursor: default;
682
+ padding: 0;
683
+ }
684
+
685
+ .thumbnail-small {
686
+ width: var(--size-10);
687
+ height: var(--size-10);
688
+ }
689
+
690
+ .thumbnail-item :global(svg) {
691
+ width: var(--size-5);
692
+ height: var(--size-5);
693
+ }
694
+
695
+ .delete-button {
696
+ position: absolute;
697
+ inset: 0;
698
+ display: flex;
699
+ justify-content: center;
700
+ align-items: center;
701
+ color: var(--button-primary-text-color);
702
+ background: rgba(0, 0, 0, 0.6);
703
+ backdrop-filter: var(--blur-xs);
704
+ border-radius: var(--radius-md);
705
+ padding: 0;
706
+ z-index: var(--layer-1);
707
+ opacity: 0;
708
+ transition: opacity 0.1s var(--easing-standard);
709
+ }
710
+
711
+ .delete-button:hover {
712
+ background: rgba(0, 0, 0, 0.8);
713
+ }
714
+
715
+ .delete-button :global(svg) {
716
+ width: var(--size-5);
717
+ height: var(--size-5);
718
+ }
719
+
720
+ .thumbnail-item:hover .delete-button {
721
+ opacity: 1;
459
722
  }
460
723
 
461
724
  textarea {
462
725
  flex-grow: 1;
463
726
  outline: none !important;
464
- background: var(--block-background-fill);
465
- padding: var(--input-padding);
727
+ background: transparent;
728
+ padding: 0;
466
729
  color: var(--body-text-color);
467
730
  font-weight: var(--input-text-weight);
468
731
  font-size: var(--input-text-size);
469
- line-height: var(--line-sm);
470
732
  border: none;
471
- margin-top: 0px;
472
- margin-bottom: 0px;
733
+ margin: 0;
473
734
  resize: none;
474
735
  position: relative;
475
- z-index: 1;
736
+ z-index: var(--layer-1);
476
737
  text-align: left;
738
+ min-height: var(--size-9);
477
739
  }
740
+
741
+ textarea:disabled {
742
+ -webkit-opacity: 1;
743
+ opacity: 1;
744
+ }
745
+
746
+ textarea::placeholder {
747
+ color: var(--input-placeholder-color);
748
+ }
749
+
478
750
  textarea[dir="rtl"] {
479
751
  text-align: right;
480
752
  }
481
753
 
482
754
  textarea[dir="rtl"] ~ .submit-button {
483
755
  order: -1;
484
- margin-left: 0;
485
- margin-right: var(--spacing-sm);
486
756
  }
487
757
 
488
758
  textarea[dir="rtl"] ~ .submit-button :global(svg) {
489
759
  transform: scaleX(-1);
490
760
  }
491
761
 
492
- textarea.no-label {
493
- padding-top: 5px;
494
- padding-bottom: 5px;
762
+ .microphone-button {
763
+ color: var(--body-text-color);
764
+ cursor: pointer;
765
+ padding: var(--spacing-sm);
766
+ display: flex;
767
+ justify-content: center;
768
+ align-items: center;
769
+ flex-shrink: 0;
770
+ border-radius: var(--radius-md);
495
771
  }
496
772
 
497
- textarea:disabled {
498
- -webkit-opacity: 1;
499
- opacity: 1;
773
+ .thumbnail-add {
774
+ background: var(--button-secondary-background-fill);
775
+ cursor: pointer;
776
+ display: flex;
777
+ justify-content: center;
778
+ align-items: center;
779
+ flex-shrink: 0;
780
+ width: var(--size-10);
781
+ height: var(--size-10);
782
+ border-radius: var(--radius-md);
783
+ z-index: var(--layer-1);
500
784
  }
501
785
 
502
- textarea::placeholder {
503
- color: var(--input-placeholder-color);
786
+ .thumbnail-add:hover:not(:disabled) {
787
+ background: var(--button-secondary-background-fill-hover);
788
+ }
789
+
790
+ .thumbnail-add:disabled {
791
+ opacity: 0.5;
792
+ cursor: not-allowed;
793
+ }
794
+
795
+ .thumbnail-add :global(svg) {
796
+ width: var(--size-5);
797
+ height: var(--size-5);
798
+ }
799
+
800
+ .microphone-button {
801
+ width: var(--size-9);
802
+ height: var(--size-9);
803
+ }
804
+
805
+ .microphone-button:hover:not(:disabled) {
806
+ background: var(--button-secondary-background-fill);
807
+ }
808
+
809
+ .microphone-button:disabled {
810
+ opacity: 0.5;
811
+ cursor: not-allowed;
812
+ }
813
+
814
+ .microphone-button :global(svg) {
815
+ width: var(--size-5);
816
+ height: var(--size-5);
504
817
  }
505
818
 
506
- .microphone-button,
507
- .upload-button,
508
819
  .submit-button,
509
820
  .stop-button {
510
- border: none;
511
- text-align: center;
512
- text-decoration: none;
513
- font-size: 14px;
821
+ background: var(--button-secondary-background-fill);
514
822
  cursor: pointer;
515
- border-radius: 15px;
516
- min-width: 30px;
517
- height: 30px;
518
- flex-shrink: 0;
519
823
  display: flex;
520
824
  justify-content: center;
521
825
  align-items: center;
826
+ flex-shrink: 0;
827
+ width: var(--size-9);
828
+ height: var(--size-9);
829
+ border-radius: var(--radius-md);
522
830
  z-index: var(--layer-1);
523
- margin-left: var(--spacing-sm);
524
- }
525
- .padded-button {
526
- padding: 0 10px;
527
831
  }
528
832
 
529
- .microphone-button,
530
- .stop-button,
531
- .upload-button,
532
- .submit-button {
533
- background: var(--button-secondary-background-fill);
534
- }
535
-
536
- .microphone-button:hover:not(:disabled),
537
- .stop-button:hover:not(:disabled),
538
- .upload-button:hover:not(:disabled),
539
- .submit-button:hover:not(:disabled) {
833
+ .submit-button:hover:not(:disabled),
834
+ .stop-button:hover:not(:disabled) {
540
835
  background: var(--button-secondary-background-fill-hover);
541
836
  }
542
837
 
543
- .microphone-button:disabled,
544
- .stop-button:disabled,
545
- .upload-button:disabled,
546
- .submit-button:disabled {
547
- background: var(--button-secondary-background-fill);
548
- cursor: not-allowed;
549
- }
550
- .microphone-button:active,
551
- .stop-button:active,
552
- .upload-button:active,
553
- .submit-button:active {
838
+ .submit-button:active:not(:disabled),
839
+ .stop-button:active:not(:disabled) {
554
840
  box-shadow: var(--button-shadow-active);
555
841
  }
556
842
 
557
- .submit-button :global(svg) {
558
- height: 22px;
559
- width: 22px;
560
- }
561
- .microphone-button :global(svg),
562
- .upload-button :global(svg) {
563
- height: 17px;
564
- width: 17px;
843
+ .submit-button:disabled,
844
+ .stop-button:disabled {
845
+ cursor: not-allowed;
565
846
  }
566
847
 
848
+ .submit-button :global(svg),
567
849
  .stop-button :global(svg) {
568
- height: 16px;
569
- width: 16px;
850
+ width: var(--size-5);
851
+ height: var(--size-5);
852
+ }
853
+
854
+ .padded-button {
855
+ padding: 0 var(--spacing-lg);
856
+ width: auto;
857
+ border-radius: var(--radius-xl);
570
858
  }
571
859
 
572
860
  .loader {
573
861
  display: flex;
574
862
  justify-content: center;
575
863
  align-items: center;
576
- --ring-color: transparent;
577
864
  position: relative;
578
- border: 5px solid #f3f3f3;
579
- border-top: 5px solid var(--color-accent);
580
- border-radius: 50%;
581
- width: 25px;
582
- height: 25px;
583
- animation: spin 2s linear infinite;
865
+ border: var(--size-1) solid var(--border-color-primary);
866
+ border-top-color: var(--color-accent);
867
+ border-radius: var(--radius-100);
868
+ width: var(--size-5);
869
+ height: var(--size-5);
870
+ animation: spin 1s linear infinite;
871
+ flex-shrink: 0;
584
872
  }
585
873
 
586
874
  @keyframes spin {
587
- 0% {
588
- transform: rotate(0deg);
589
- }
590
- 100% {
875
+ to {
591
876
  transform: rotate(360deg);
592
877
  }
593
878
  }
594
879
 
595
- .thumbnails :global(img) {
596
- width: var(--size-full);
597
- height: var(--size-full);
598
- object-fit: cover;
599
- border-radius: var(--radius-lg);
880
+ .recording-overlay {
881
+ position: absolute;
882
+ top: 0;
883
+ left: 0;
884
+ right: 0;
885
+ bottom: 0;
886
+ background: var(--block-background-fill);
887
+ border-radius: var(--radius-xl);
888
+ display: flex;
889
+ align-items: center;
890
+ justify-content: center;
891
+ z-index: var(--layer-5);
892
+ padding: var(--spacing-lg);
893
+ backdrop-filter: blur(8px);
894
+ animation: fadeIn 0.2s var(--easing-standard);
600
895
  }
601
896
 
602
- .thumbnails {
897
+ @keyframes fadeIn {
898
+ from {
899
+ opacity: 0;
900
+ }
901
+ to {
902
+ opacity: 1;
903
+ }
904
+ }
905
+
906
+ .recording-content {
603
907
  display: flex;
604
908
  align-items: center;
605
909
  gap: var(--spacing-lg);
606
- overflow-x: scroll;
607
- padding-top: var(--spacing-sm);
608
- margin-bottom: 6px;
910
+ width: 100%;
911
+ max-width: 700px;
609
912
  }
610
913
 
611
- .thumbnail-item {
914
+ .recording-content :global(.minimal-audio-recorder),
915
+ .recording-content :global(.minimal-audio-player) {
916
+ flex: 1;
917
+ }
918
+
919
+ .action-buttons {
612
920
  display: flex;
613
- justify-content: center;
614
921
  align-items: center;
615
- --ring-color: transparent;
616
- position: relative;
617
- box-shadow:
618
- 0 0 0 2px var(--ring-color),
619
- var(--shadow-drop);
620
- border: 1px solid var(--border-color-primary);
621
- border-radius: var(--radius-lg);
622
- background: var(--background-fill-secondary);
623
- aspect-ratio: var(--ratio-square);
624
- width: var(--size-full);
625
- height: var(--size-full);
626
- cursor: default;
922
+ gap: var(--spacing-sm);
923
+ flex-shrink: 0;
627
924
  }
628
925
 
629
- .thumbnail-small {
630
- flex: none;
631
- transform: scale(0.9);
632
- transition: 0.075s;
633
- width: var(--size-12);
634
- height: var(--size-12);
926
+ .stop-button,
927
+ .confirm-button,
928
+ .cancel-button {
929
+ display: flex;
930
+ align-items: center;
931
+ justify-content: center;
932
+ width: var(--size-9);
933
+ height: var(--size-9);
934
+ padding: 0;
935
+ border-radius: var(--radius-md);
936
+ cursor: pointer;
937
+ flex-shrink: 0;
635
938
  }
636
939
 
637
- .thumbnail-item :global(svg) {
638
- width: 30px;
639
- height: 30px;
940
+ .stop-button {
941
+ background: var(--button-secondary-background-fill);
942
+ border-color: var(--border-color-primary);
943
+ color: var(--error-500);
640
944
  }
641
945
 
642
- .delete-button {
643
- display: flex;
644
- justify-content: center;
645
- align-items: center;
646
- position: absolute;
647
- right: -7px;
648
- top: -7px;
649
- color: var(--button-secondary-text-color);
946
+ .stop-button:hover {
947
+ background: var(--button-secondary-background-fill-hover);
948
+ color: var(--error-600);
949
+ }
950
+
951
+ .stop-button:active {
952
+ transform: scale(0.95);
953
+ }
954
+
955
+ .confirm-button {
956
+ background: var(--button-primary-background-fill);
957
+ border-color: var(--button-primary-border-color);
958
+ color: white;
959
+ }
960
+
961
+ .confirm-button:hover {
962
+ background: var(--button-primary-background-fill-hover);
963
+ color: var(--button-primary-text-color-hover);
964
+ }
965
+
966
+ .confirm-button:active {
967
+ transform: scale(0.95);
968
+ }
969
+
970
+ .cancel-button {
650
971
  background: var(--button-secondary-background-fill);
651
- border: none;
652
- text-align: center;
653
- text-decoration: none;
654
- font-size: 10px;
655
- cursor: pointer;
656
- border-radius: 50%;
657
- width: 20px;
658
- height: 20px;
972
+ color: var(--body-text-color);
659
973
  }
660
974
 
661
- .disabled {
662
- display: none;
975
+ .cancel-button:hover {
976
+ background: var(--button-secondary-background-fill-hover);
663
977
  }
664
978
 
665
- .delete-button :global(svg) {
666
- width: 12px;
667
- height: 12px;
979
+ .stop-button :global(svg),
980
+ .confirm-button :global(svg),
981
+ .cancel-button :global(svg) {
982
+ width: var(--size-5);
983
+ height: var(--size-5);
668
984
  }
669
985
 
670
- .delete-button:hover {
671
- filter: brightness(1.2);
672
- border: 0.8px solid var(--color-grey-500);
986
+ @media (max-width: 768px) {
987
+ .input-wrapper {
988
+ padding: var(--spacing-xs);
989
+ }
990
+
991
+ .thumbnails {
992
+ padding: var(--spacing-xs) 0;
993
+ margin-bottom: var(--spacing-md);
994
+ }
995
+
996
+ .thumbnail-small,
997
+ .thumbnail-add {
998
+ width: var(--size-9);
999
+ height: var(--size-9);
1000
+ }
1001
+
1002
+ .thumbnail-item:active .delete-button {
1003
+ opacity: 1;
1004
+ }
673
1005
  }
674
1006
  </style>