99 lines
2.7 KiB
TypeScript
99 lines
2.7 KiB
TypeScript
|
|
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||
|
|
|
||
|
|
interface DropZoneProps {
|
||
|
|
accept?: string;
|
||
|
|
multiple?: boolean;
|
||
|
|
onFiles: (files: File[]) => void;
|
||
|
|
children?: ReactNode;
|
||
|
|
className?: string;
|
||
|
|
label?: string;
|
||
|
|
hint?: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function DropZone({
|
||
|
|
accept = "image/*",
|
||
|
|
multiple = false,
|
||
|
|
onFiles,
|
||
|
|
children,
|
||
|
|
className = "",
|
||
|
|
label = "拖入文件或点击上传",
|
||
|
|
hint,
|
||
|
|
disabled = false,
|
||
|
|
}: DropZoneProps) {
|
||
|
|
const [isDragging, setIsDragging] = useState(false);
|
||
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
|
const dragCounter = useRef(0);
|
||
|
|
|
||
|
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
dragCounter.current += 1;
|
||
|
|
setIsDragging(true);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
dragCounter.current -= 1;
|
||
|
|
if (dragCounter.current <= 0) {
|
||
|
|
dragCounter.current = 0;
|
||
|
|
setIsDragging(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleDrop = useCallback(
|
||
|
|
(e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
dragCounter.current = 0;
|
||
|
|
setIsDragging(false);
|
||
|
|
if (disabled) return;
|
||
|
|
const acceptTypes = accept.split(",").map((t) => t.trim());
|
||
|
|
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
||
|
|
acceptTypes.some((t) => {
|
||
|
|
if (t.endsWith("/*")) return f.type.startsWith(t.replace("/*", "/"));
|
||
|
|
return f.type === t || f.name.endsWith(t);
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
if (files.length) onFiles(multiple ? files : files.slice(0, 1));
|
||
|
|
},
|
||
|
|
[accept, disabled, multiple, onFiles],
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleChange = useCallback(
|
||
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const files = Array.from(e.target.files || []);
|
||
|
|
if (files.length) onFiles(multiple ? files : files.slice(0, 1));
|
||
|
|
e.target.value = "";
|
||
|
|
},
|
||
|
|
[multiple, onFiles],
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={`dropzone${isDragging ? " dropzone--active" : ""} ${className}`}
|
||
|
|
onDragEnter={handleDragEnter}
|
||
|
|
onDragOver={(e) => e.preventDefault()}
|
||
|
|
onDragLeave={handleDragLeave}
|
||
|
|
onDrop={handleDrop}
|
||
|
|
onClick={() => !disabled && inputRef.current?.click()}
|
||
|
|
role="button"
|
||
|
|
tabIndex={0}
|
||
|
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
type="file"
|
||
|
|
accept={accept}
|
||
|
|
multiple={multiple}
|
||
|
|
onChange={handleChange}
|
||
|
|
style={{ display: "none" }}
|
||
|
|
/>
|
||
|
|
{children || (
|
||
|
|
<>
|
||
|
|
<strong className="dropzone__label">{label}</strong>
|
||
|
|
{hint && <span className="dropzone__hint">{hint}</span>}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|