fix(files): make dropped-file handling explicit

This commit is contained in:
2026-07-20 16:57:29 +02:00
parent 78b4afdec4
commit b5cfba666c
6 changed files with 345 additions and 6 deletions

View File

@@ -1,5 +1,8 @@
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react";
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react";
import { UploadCloud } from "lucide-react";
import { resolveDroppedFiles } from "./resolveDroppedFiles";
type RejectedDropReason = "disabled" | "empty" | "unreadable";
export type FileDropZoneProps = {
accept?: string;
@@ -14,6 +17,7 @@ export type FileDropZoneProps = {
note?: ReactNode;
className?: string;
inputLabel?: string;
onRejectedDrop?: (reason: RejectedDropReason) => void;
onFiles: (files: File[]) => void | Promise<void>;
};
@@ -30,6 +34,7 @@ export default function FileDropZone({
note,
className = "",
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
onRejectedDrop,
onFiles
}: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -52,6 +57,13 @@ export default function FileDropZone({
}
}
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
if (!interactionDisabled) setDragActive(true);
}
return (
<>
<div
@@ -70,15 +82,29 @@ export default function FileDropZone({
inputRef.current?.click();
}
}}
onDragOver={(event) => {
onDragEnter={prepareFileDrag}
onDragOver={prepareFileDrag}
onDragLeave={(event) => {
event.preventDefault();
if (!interactionDisabled) setDragActive(true);
event.stopPropagation();
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
setDragActive(false);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setDragActive(false);
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
if (interactionDisabled) {
onRejectedDrop?.("disabled");
return;
}
void resolveDroppedFiles(event.dataTransfer).then((files) => {
if (files.length === 0) {
onRejectedDrop?.("empty");
return;
}
void handleFiles(files);
}).catch(() => onRejectedDrop?.("unreadable"));
}}>
{showProgress ?
@@ -110,4 +136,4 @@ export default function FileDropZone({
</>);
}
}

View File

@@ -0,0 +1,101 @@
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
type FileSystemDirectoryHandleLike = {
kind: "directory";
values?: () => AsyncIterable<FileSystemHandleLike>;
};
type WebKitFileEntryLike = {
isFile: true;
isDirectory: false;
file: (
success: (file: File) => void,
error?: (error: DOMException) => void
) => void;
};
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
type DropItemWithFileHandles = DataTransferItem & {
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
webkitGetAsEntry?: () => unknown;
};
/**
* Resolve a browser file drop exactly once, preferring the standard file list
* before progressively trying item and directory-drag compatibility APIs.
*/
export async function resolveDroppedFiles(
dataTransfer: Pick<DataTransfer, "files" | "items">
): Promise<File[]> {
const directFiles = Array.from(dataTransfer.files);
if (directFiles.length > 0) return directFiles;
const items = Array.from(dataTransfer.items).filter(
(item) => item.kind === "file"
) as DropItemWithFileHandles[];
const itemFiles = items
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (itemFiles.length > 0) return itemFiles;
const handleFiles = await filesFromDataTransferHandles(items);
if (handleFiles.length > 0) return handleFiles;
return filesFromWebKitEntries(items);
}
async function filesFromDataTransferHandles(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const files: File[] = [];
for (const item of items) {
let handle: FileSystemHandleLike | null | undefined;
try {
handle = await item.getAsFileSystemHandle?.();
} catch {
handle = null;
}
if (!handle) continue;
files.push(...await filesFromFileSystemHandle(handle));
}
return files;
}
async function filesFromFileSystemHandle(
handle: FileSystemHandleLike
): Promise<File[]> {
if (handle.kind === "file") return [await handle.getFile()];
const values = handle.values?.();
if (!values) return [];
const files: File[] = [];
for await (const child of values) {
files.push(...await filesFromFileSystemHandle(child));
}
return files;
}
async function filesFromWebKitEntries(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const entries = items.flatMap((item) => {
const entry = item.webkitGetAsEntry?.() as unknown;
return isWebKitEntryLike(entry) ? [entry] : [];
});
const files = await Promise.all(entries.map(fileFromWebKitEntry));
return files.filter((file): file is File => Boolean(file));
}
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
if (!value || typeof value !== "object") return false;
const entry = value as { isFile?: unknown; isDirectory?: unknown };
return (
typeof entry.isFile === "boolean" &&
typeof entry.isDirectory === "boolean"
);
}
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
if (!entry.isFile) return Promise.resolve(null);
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}