Skip to content

Commit 3ff70f2

Browse files
committed
Implement MultimodalFileInput component and use for patch input, file comparison
1 parent da265cc commit 3ff70f2

File tree

10 files changed

+423
-278
lines changed

10 files changed

+423
-278
lines changed

web/src/app.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,7 @@
121121
.png-bg {
122122
background: url("/png.gif") right bottom var(--color-gray-300);
123123
}
124+
125+
textarea {
126+
vertical-align: bottom;
127+
}

web/src/lib/components/files/DirectorySelect.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
{#if directory}
1717
{directory.fileName}
1818
{:else}
19-
{placeholder}
19+
<span class="font-light">{placeholder}</span>
2020
{/if}
2121
<span class="iconify size-4 shrink-0 text-em-disabled octicon--triangle-down-16"></span>
2222
{/snippet}

web/src/lib/components/files/FileInput.svelte

Lines changed: 0 additions & 68 deletions
This file was deleted.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<script lang="ts">
2+
import { type MultimodalFileInputProps, MultimodalFileInputState } from "$lib/components/files/index.svelte";
3+
import { box } from "svelte-toolbelt";
4+
import { RadioGroup } from "bits-ui";
5+
import SingleFileInput from "$lib/components/files/SingleFileInput.svelte";
6+
7+
let { label = "File", state = $bindable() }: MultimodalFileInputProps = $props();
8+
9+
const instance = new MultimodalFileInputState({
10+
label: box.with(() => label),
11+
state,
12+
});
13+
state = instance;
14+
15+
function handleDragOver(event: DragEvent) {
16+
instance.dragActive = true;
17+
event.preventDefault();
18+
}
19+
20+
function handleDragLeave(event: DragEvent) {
21+
if (event.currentTarget === event.target) {
22+
instance.dragActive = false;
23+
}
24+
event.preventDefault();
25+
}
26+
27+
async function handleFileDrop(event: DragEvent) {
28+
instance.dragActive = false;
29+
event.preventDefault();
30+
const files = event.dataTransfer?.files;
31+
if (!files || files.length !== 1) {
32+
alert("Only one file can be dropped at a time.");
33+
return;
34+
}
35+
instance.file = files[0];
36+
instance.mode = "file";
37+
}
38+
</script>
39+
40+
{#snippet radioItem(name: string)}
41+
<RadioGroup.Item value={name.toLowerCase()}>
42+
{#snippet children({ checked })}
43+
<span class="rounded-sm px-1 py-0.5 text-sm" class:btn-ghost={!checked} class:border={!checked} class:btn-primary={checked}>
44+
{name}
45+
</span>
46+
{/snippet}
47+
</RadioGroup.Item>
48+
{/snippet}
49+
50+
<!-- svelte-ignore a11y_no_static_element_interactions -->
51+
<div
52+
class="file-drop-target w-full"
53+
data-drag-active={instance.dragActive}
54+
ondragover={handleDragOver}
55+
ondrop={handleFileDrop}
56+
ondragleavecapture={handleDragLeave}
57+
>
58+
<RadioGroup.Root class="mb-1 flex w-full gap-1" bind:value={instance.mode}>
59+
{@render radioItem("File")}
60+
{@render radioItem("URL")}
61+
{@render radioItem("Text")}
62+
</RadioGroup.Root>
63+
{#if instance.mode === "file"}
64+
{@render fileInput()}
65+
{:else if instance.mode === "url"}
66+
{@render urlInput()}
67+
{:else if instance.mode === "text"}
68+
{@render textInput()}
69+
{/if}
70+
</div>
71+
72+
{#snippet fileInput()}
73+
<SingleFileInput bind:file={instance.file} class="flex w-fit items-center gap-2 rounded-md border btn-ghost px-2 py-1 has-focus-visible:outline-2">
74+
<span class="iconify size-4 shrink-0 text-em-disabled octicon--file-16"></span>
75+
{#if instance.file}
76+
{instance.file.name}
77+
{:else}
78+
<span class="font-light">{label}</span>
79+
{/if}
80+
<span class="iconify size-4 shrink-0 text-em-disabled octicon--triangle-down-16"></span>
81+
</SingleFileInput>
82+
{/snippet}
83+
84+
{#snippet urlInput()}
85+
<input title="{label} URL" placeholder="Enter file URL" bind:value={instance.url} type="url" class="w-full rounded-md border px-2 py-1" />
86+
{/snippet}
87+
88+
{#snippet textInput()}
89+
<textarea title="{label} Text" bind:value={instance.text} placeholder="Enter text here" class="w-full rounded-md border px-2 py-1"></textarea>
90+
{/snippet}
91+
92+
<style>
93+
.file-drop-target {
94+
position: relative;
95+
}
96+
.file-drop-target[data-drag-active="true"]::before {
97+
width: 100%;
98+
height: 100%;
99+
position: absolute;
100+
top: 0;
101+
left: 0;
102+
display: flex;
103+
align-items: center;
104+
justify-content: center;
105+
106+
content: "Drop file here";
107+
font-size: var(--text-3xl);
108+
color: var(--color-black);
109+
110+
background-color: rgba(255, 255, 255, 0.7);
111+
112+
border: dashed var(--color-primary);
113+
border-radius: inherit;
114+
}
115+
</style>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script lang="ts">
2+
import { useId } from "bits-ui";
3+
import { type RestProps } from "$lib/types";
4+
import { type Snippet } from "svelte";
5+
import { watch } from "runed";
6+
7+
type Props = {
8+
children?: Snippet<[{ file?: File }]>;
9+
file?: File;
10+
} & RestProps;
11+
12+
let { children, file = $bindable<File | undefined>(undefined), ...restProps }: Props = $props();
13+
14+
let files = $state<FileList | undefined>();
15+
16+
watch(
17+
() => files,
18+
(newFiles) => {
19+
if (newFiles && newFiles.length > 0) {
20+
file = newFiles[0];
21+
}
22+
},
23+
);
24+
25+
const labelId = useId();
26+
const inputId = useId();
27+
</script>
28+
29+
<label id={labelId} for={inputId} {...restProps}>
30+
{@render children?.({ file })}
31+
<input id={inputId} aria-labelledby={labelId} type="file" bind:files class="sr-only" />
32+
</label>

web/src/lib/components/files/SingleFileSelect.svelte

Lines changed: 0 additions & 20 deletions
This file was deleted.

web/src/lib/components/files/index.svelte.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { type ReadableBoxedValues } from "svelte-toolbelt";
2+
import { lazyPromise } from "$lib/util";
3+
14
export interface FileSystemEntry {
25
fileName: string;
36
}
@@ -128,3 +131,118 @@ function filesToDirectory(files: FileList): DirectoryEntry {
128131

129132
return ret;
130133
}
134+
135+
export type FileInputMode = "file" | "url" | "text";
136+
137+
export type MultimodalFileInputValueMetadata = {
138+
type: FileInputMode;
139+
name: string;
140+
};
141+
142+
export type MultimodalFileInputProps = {
143+
state?: MultimodalFileInputState | undefined;
144+
145+
label?: string | undefined;
146+
};
147+
148+
export type MultimodalFileInputStateProps = {
149+
state: MultimodalFileInputState | undefined;
150+
} & ReadableBoxedValues<{
151+
label: string;
152+
}>;
153+
154+
export class MultimodalFileInputState {
155+
private readonly opts: MultimodalFileInputStateProps;
156+
mode: FileInputMode = $state("file");
157+
text: string = $state("");
158+
file: File | undefined = $state(undefined);
159+
url: string = $state("");
160+
private urlResolver = $derived.by(() => {
161+
let url = this.url;
162+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
163+
url = `https://${url}`;
164+
}
165+
return lazyPromise(async () => {
166+
let threw = false;
167+
try {
168+
const response = await fetch(url);
169+
if (!response.ok) {
170+
threw = true;
171+
throw new Error(`Failed to fetch from URL: ${url}\nStatus: ${response.status}\nBody:\n${await response.text()}`);
172+
}
173+
return await response.blob();
174+
} catch (e) {
175+
if (threw) {
176+
throw e;
177+
}
178+
throw new Error(`Failed to fetch from URL: ${url}\nSome errors, such as those caused by CORS, will only print in the console.\nCause: ${e}`);
179+
}
180+
});
181+
});
182+
dragActive = $state(false);
183+
184+
constructor(opts: MultimodalFileInputStateProps) {
185+
this.opts = opts;
186+
if (this.opts.state) {
187+
this.mode = this.opts.state.mode;
188+
this.text = this.opts.state.text;
189+
this.file = this.opts.state.file;
190+
this.url = this.opts.state.url;
191+
this.urlResolver = this.opts.state.urlResolver;
192+
}
193+
}
194+
195+
get metadata(): MultimodalFileInputValueMetadata | null {
196+
const mode = this.mode;
197+
const label = this.opts.label.current;
198+
if (mode === "file" && this.file !== undefined) {
199+
const file = this.file;
200+
return { type: "file", name: file.name };
201+
} else if (mode === "url" && this.url !== "") {
202+
return { type: "url", name: this.url };
203+
} else if (mode === "text" && this.text !== "") {
204+
return { type: "text", name: `${label} (Text Input)` };
205+
} else {
206+
return null;
207+
}
208+
}
209+
210+
async resolve(): Promise<Blob> {
211+
const mode = this.mode;
212+
if (mode === "file" && this.file !== undefined) {
213+
return this.file;
214+
} else if (mode === "url" && this.url !== "") {
215+
return this.urlResolver.getValue();
216+
} else if (mode === "text" && this.text !== "") {
217+
return new Blob([this.text], { type: "text/plain" });
218+
} else {
219+
throw Error("No value present");
220+
}
221+
}
222+
223+
reset() {
224+
this.text = "";
225+
this.file = undefined;
226+
this.url = "";
227+
}
228+
229+
swapState(other: MultimodalFileInputState) {
230+
const mode = this.mode;
231+
const text = this.text;
232+
const file = this.file;
233+
const url = this.url;
234+
const urlResolver = this.urlResolver;
235+
236+
this.mode = other.mode;
237+
this.text = other.text;
238+
this.file = other.file;
239+
this.url = other.url;
240+
this.urlResolver = other.urlResolver;
241+
242+
other.mode = mode;
243+
other.text = text;
244+
other.file = file;
245+
other.url = url;
246+
other.urlResolver = urlResolver;
247+
}
248+
}

0 commit comments

Comments
 (0)