Files
windmill/frontend/src/lib/components/common/fileInput/FileInput.svelte
T
Diego ImbertandClaude Opus 4.6 5d79f33590 Final Svelte 5 migration (#8211)
* Remove $$props.field usage

* Rename slots to ensure no hyphen

* _props

* _trigger

* OnSelectedIteration type correct capitalization

* rename _content

* Remove afterUpdate

* Migrate everything to svelte 5

* array bind

* Fix popover

* type never

* nit fixes

* Fixed many trivial errors

* onClick

* Fix errors

* use let:

* nit typing

* fix: wrap state_referenced_locally vars with untrack()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add untrack import

* Fix all syntax errors due to untrack migration

* Fix undefined errors

* Fix more undefined errors

* untrack(() => initialOpen)

* svelte-ignore

* Fix state_descriptors_fixed error in Chart.svelte

Use $state.snapshot() to pass plain copies of data/options to Chart.js
instead of $state proxies. Chart.js's listenArrayEvents tries to define
property descriptors on data arrays, which Svelte 5 proxies reject.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* nit typing

* Merge issue

* Fix "path is not set" error in resource picker / editor

* Fix InputTransformForm error when rerunning some flows

* fix npm run check

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 18:11:40 +01:00

286 lines
7.4 KiB
Svelte

<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { FileUp, Trash } from 'lucide-svelte'
import Button from '../../common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
import type { ReadFileAs } from './model'
import { sendUserToast } from '$lib/toast'
type ConvertedFile = string | ArrayBuffer | null
const dispatch = createEventDispatcher()
let input: HTMLInputElement | undefined = $state()
type FileWithPath = File & { path?: string }
interface Props {
class?: string
style?: string
accept?: string
multiple?: boolean
convertTo?: ReadFileAs | undefined
hideIcon?: boolean
iconSize?: number
returnFileNames?: boolean
submittedText?: string | undefined
defaultFile?: string | string[] | undefined
disabled?: boolean | undefined
folderOnly?: boolean
files?: FileWithPath[] | undefined
selectedTitle?: import('svelte').Snippet
children?: import('svelte').Snippet
[key: string]: any
}
let {
class: c = '',
style = '',
accept = '*',
multiple = false,
convertTo = undefined,
hideIcon = false,
iconSize = 24,
returnFileNames = false,
submittedText = undefined,
defaultFile = undefined,
disabled = undefined,
folderOnly = false,
files = $bindable(undefined),
selectedTitle,
children,
...rest
}: Props = $props()
let pointerStartX = $state(0)
let pointerStartY = $state(0)
function handlePointerDown(e: PointerEvent) {
pointerStartX = e.clientX
pointerStartY = e.clientY
}
async function onChange(fileList: FileWithPath[] | null) {
if (!fileList || !fileList.length) {
files = undefined
dispatch('change', files)
return
}
if (multiple && files) {
files = [...files, ...fileList]
} else {
files = fileList
}
// Needs to be reset so the same file can be selected
// multiple times in a row
if (input) input.value = ''
dispatchChange()
}
async function convertFile(file: File): Promise<string | ArrayBuffer | null> {
return new Promise((resolve) => {
if (!convertTo) {
return resolve(null)
}
const reader = new FileReader()
reader.onloadend = () => {
resolve(reader.result)
}
switch (convertTo) {
case 'buffer':
reader.readAsArrayBuffer(file)
break
case 'binary':
reader.readAsBinaryString(file)
break
case 'base64':
reader.readAsDataURL(file)
break
case 'text':
reader.readAsText(file)
break
}
})
}
function handleDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
}
async function handleFile(fileEntry: FileSystemFileEntry): Promise<FileWithPath> {
return new Promise((resolve, reject) => {
fileEntry.file(resolve, reject)
})
}
async function handleDirectory(
dirEntry: FileSystemDirectoryEntry,
path: string
): Promise<FileWithPath[]> {
const files: FileWithPath[] = []
const dirReader = dirEntry.createReader()
async function readEntries() {
return new Promise<FileWithPath[]>((resolve) => {
dirReader.readEntries(async (entries) => {
if (entries.length === 0) {
resolve(files)
return
}
const filePromises = entries.map(async (entry) => {
return traverseFileTree(entry, path + dirEntry.name + '/')
})
const nestedFiles = await Promise.all(filePromises)
files.push(...nestedFiles.flat())
// readEntries only return up to 100 files
// continue reading if more files exist
resolve(await readEntries())
})
})
}
return readEntries()
}
async function traverseFileTree(entry: FileSystemEntry, path = ''): Promise<FileWithPath[]> {
if (entry.isFile) {
const file = await handleFile(entry as FileSystemFileEntry)
file.path = path + file.name
return [file]
} else if (entry.isDirectory) {
return handleDirectory(entry as FileSystemDirectoryEntry, path)
}
return []
}
async function handleDrop(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
if (folderOnly) {
const item = event.dataTransfer.items[0]?.webkitGetAsEntry()
if (item) {
const files = await traverseFileTree(item, '')
onChange(files)
}
} else {
if (event.dataTransfer.files && event.dataTransfer.files.length) {
if (!multiple && event.dataTransfer.files.length > 1) {
sendUserToast('Only one file can be uploaded at a time')
return
} else {
onChange(Array.from(event.dataTransfer.files))
}
}
}
}
}
function removeFile(index: number) {
if (!files) return
files.splice(index, 1)
files = files.length ? files : undefined
dispatchChange()
}
async function dispatchChange() {
files = files
if (convertTo && files) {
const promises = files.map(convertFile)
let converted: ConvertedFile[] | { name: string; data: ConvertedFile }[] =
await Promise.all(promises)
if (returnFileNames) {
converted = converted.map((c, i) => ({ name: files![i].name, data: c }))
}
dispatch('change', converted)
} else {
dispatch('change', files)
}
}
export function clearFiles() {
files = undefined
dispatchChange()
}
</script>
<button
class={twMerge(
`relative center-center flex flex-col gap-x-2 gap-y-1 justify-center items-center flex-wrap text-center font-normal text-hint text-xs rounded-md
bg-surface-secondary
border border-dashed border-nord-400 dark:border-nord-300 hover:border-nord-900 dark:hover:border-nord-900
focus-within:border-blue-300 hover:bg-blue-50 dark:hover:bg-frost-900
duration-200 px-1 py-8`,
c
)}
ondragover={handleDragOver}
ondrop={handleDrop}
onpointerdown={handlePointerDown}
onclick={(e) => {
const deltaX = Math.abs(e.clientX - pointerStartX)
const deltaY = Math.abs(e.clientY - pointerStartY)
if (deltaX > 5 || deltaY > 5) {
e.preventDefault()
e.stopPropagation()
return
}
}}
{style}
{disabled}
>
{#if !hideIcon && !files}
<FileUp size={iconSize} />
{/if}
{#if files}
<div class="w-full max-h-full overflow-auto px-6">
{#if selectedTitle}{@render selectedTitle()}{:else}
<div class="text-center mb-2 px-2">
{submittedText ? submittedText : `Selected file${files.length > 1 ? 's' : ''}`}:
</div>
{/if}
<ul class="relative z-20 max-w-[500px] bg-surface rounded-lg overflow-hidden mx-auto">
{#each files as { name }, i}
<li
class="flex justify-between items-center font-normal text-sm
hover:bg-gray-300/20 duration-200 py-1 px-2 cursor-default"
>
<span class="pr-2 ellipsize">{name}</span>
<Button
size="xs"
variant="default"
iconOnly
btnClasses="bg-transparent"
startIcon={{ icon: Trash }}
on:click={() => removeFile(i)}
destructive
/>
</li>
{/each}
</ul>
</div>
{:else if children}{@render children()}{:else}
<span>Drag and drop {folderOnly ? 'a folder' : multiple ? 'files' : 'a file'}</span>
{/if}
<input
class="!absolute !inset-0 !z-10 !opacity-0 !cursor-pointer"
type="file"
{...{ webkitdirectory: folderOnly }}
title={files ? `${files.length} file${files.length > 1 ? 's' : ''} chosen` : 'No file chosen'}
bind:this={input}
onchange={({ currentTarget }) => {
onChange(currentTarget.files ? Array.from(currentTarget.files) : null)
}}
{accept}
{multiple}
{...rest}
/>
{#if defaultFile && (!Array.isArray(defaultFile) || defaultFile.length > 0)}
<div class="w-full border-dashed border-t-2 text-2xs pt-1 text-primary mt-2">
Default file: <span class="text-nord-900">{defaultFile}</span>
</div>
{/if}
</button>