use react dropzone on chat input

This commit is contained in:
Cole Goldsmith 2025-11-18 14:21:09 -06:00
parent 98cb6c5198
commit 3ad90f7293
3 changed files with 442 additions and 514 deletions

View file

@ -1,339 +1,416 @@
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverAnchor,
PopoverContent,
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import { useFileDrag } from "@/hooks/use-file-drag";
import { cn } from "@/lib/utils";
import type { KnowledgeFilterData } from "../_types/types";
import { FilePreview } from "./file-preview";
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
export interface ChatInputHandle {
focusInput: () => void;
clickFileInput: () => void;
focusInput: () => void;
clickFileInput: () => void;
}
interface ChatInputProps {
input: string;
loading: boolean;
isUploading: boolean;
selectedFilter: KnowledgeFilterData | null;
isFilterDropdownOpen: boolean;
availableFilters: KnowledgeFilterData[];
filterSearchTerm: string;
selectedFilterIndex: number;
anchorPosition: { x: number; y: number } | null;
parsedFilterData: { color?: FilterColor } | null;
uploadedFile: File | null;
onSubmit: (e: React.FormEvent) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
onAtClick: () => void;
onFilePickerClick: () => void;
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
setIsFilterHighlighted: (highlighted: boolean) => void;
setIsFilterDropdownOpen: (open: boolean) => void;
onFileSelected: (file: File | null) => void;
input: string;
loading: boolean;
isUploading: boolean;
selectedFilter: KnowledgeFilterData | null;
isFilterDropdownOpen: boolean;
availableFilters: KnowledgeFilterData[];
filterSearchTerm: string;
selectedFilterIndex: number;
anchorPosition: { x: number; y: number } | null;
parsedFilterData: { color?: FilterColor } | null;
uploadedFile: File | null;
onSubmit: (e: React.FormEvent) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
onAtClick: () => void;
onFilePickerClick: () => void;
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
setIsFilterHighlighted: (highlighted: boolean) => void;
setIsFilterDropdownOpen: (open: boolean) => void;
onFileSelected: (file: File | null) => void;
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
(
{
input,
loading,
isUploading,
selectedFilter,
isFilterDropdownOpen,
availableFilters,
filterSearchTerm,
selectedFilterIndex,
anchorPosition,
parsedFilterData,
uploadedFile,
onSubmit,
onChange,
onKeyDown,
onFilterSelect,
onAtClick,
onFilePickerClick,
setSelectedFilter,
setIsFilterHighlighted,
setIsFilterDropdownOpen,
onFileSelected,
},
ref,
) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [textareaHeight, setTextareaHeight] = useState(0);
(
{
input,
loading,
isUploading,
selectedFilter,
isFilterDropdownOpen,
availableFilters,
filterSearchTerm,
selectedFilterIndex,
anchorPosition,
parsedFilterData,
uploadedFile,
onSubmit,
onChange,
onKeyDown,
onFilterSelect,
onAtClick,
onFilePickerClick,
setSelectedFilter,
setIsFilterHighlighted,
setIsFilterDropdownOpen,
onFileSelected,
},
ref,
) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [textareaHeight, setTextareaHeight] = useState(0);
const [fileUploadError, setFileUploadError] = useState<Error | null>(null);
const isDragging = useFileDrag();
useImperativeHandle(ref, () => ({
focusInput: () => {
inputRef.current?.focus();
},
clickFileInput: () => {
fileInputRef.current?.click();
},
}));
const { getRootProps, getInputProps } = useDropzone({
accept: {
"application/pdf": [".pdf"],
"application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[".docx"],
"text/markdown": [".md"],
},
maxFiles: 1,
disabled: !isDragging,
onDrop: (acceptedFiles, fileRejections) => {
setFileUploadError(null);
if (fileRejections.length > 0) {
console.log(fileRejections);
const message = fileRejections.at(0)?.errors.at(0)?.message;
setFileUploadError(new Error(message));
return;
}
onFileSelected(acceptedFiles[0]);
},
});
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileSelected(files[0]);
} else {
onFileSelected(null);
}
};
useImperativeHandle(ref, () => ({
focusInput: () => {
inputRef.current?.focus();
},
clickFileInput: () => {
fileInputRef.current?.click();
},
}));
return (
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div className="flex flex-col w-full gap-2 rounded-xl border border-input hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground p-2 transition-colors">
{/* File Preview Section - Always above */}
{uploadedFile && (
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
)}
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFileUploadError(null);
const files = e.target.files;
if (files && files.length > 0) {
onFileSelected(files[0]);
} else {
onFileSelected(null);
}
};
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div
className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
}`}
>
{/* Filter + Textarea Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
>
{textareaHeight <= 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
))}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
autoComplete="off"
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
/>
</div>
</div>
return (
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div
{...getRootProps()}
className={cn(
"flex flex-col w-full p-2 rounded-xl border border-input transition-all",
!isDragging &&
"hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground",
isDragging && "border-dashed",
)}
>
<input {...getInputProps()} />
{/* File Preview Section - Always above */}
<AnimatePresence>
{uploadedFile && (
<motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: "auto", marginBottom: 8 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
className="overflow-hidden"
>
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{fileUploadError && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="text-sm text-destructive overflow-hidden"
>
{fileUploadError.message}
</motion.p>
)}
</AnimatePresence>
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 100 }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden w-full flex flex-col items-center justify-center gap-2"
>
<p className="text-md font-medium text-primary">
Add files to conversation
</p>
<p className="text-sm text-muted-foreground">
Text formats and image files.{" "}
<span className="font-semibold">10</span> files per chat,{" "}
<span className="font-semibold">150 MB</span> each.
</p>
</motion.div>
)}
</AnimatePresence>
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div
className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
}`}
>
{/* Filter + Textarea Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
>
{textareaHeight <= 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
))}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
autoComplete="off"
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
/>
</div>
</div>
{/* Action Buttons Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
>
{textareaHeight > 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
))}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="default"
type="submit"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
{/* Action Buttons Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
>
{textareaHeight > 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
))}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="default"
type="submit"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
);
},
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
);
},
);
ChatInput.displayName = "ChatInput";

View file

@ -1,202 +0,0 @@
'use client';
import { UploadIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { createContext, useContext } from 'react';
import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type DropzoneContextType = {
src?: File[];
accept?: DropzoneOptions['accept'];
maxSize?: DropzoneOptions['maxSize'];
minSize?: DropzoneOptions['minSize'];
maxFiles?: DropzoneOptions['maxFiles'];
};
const renderBytes = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)}${units[unitIndex]}`;
};
const DropzoneContext = createContext<DropzoneContextType | undefined>(
undefined
);
export type DropzoneProps = Omit<DropzoneOptions, 'onDrop'> & {
src?: File[];
className?: string;
onDrop?: (
acceptedFiles: File[],
fileRejections: FileRejection[],
event: DropEvent
) => void;
children?: ReactNode;
};
export const Dropzone = ({
accept,
maxFiles = 1,
maxSize,
minSize,
onDrop,
onError,
disabled,
src,
className,
children,
...props
}: DropzoneProps) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept,
maxFiles,
maxSize,
minSize,
onError,
disabled,
onDrop: (acceptedFiles, fileRejections, event) => {
if (fileRejections.length > 0) {
const message = fileRejections.at(0)?.errors.at(0)?.message;
onError?.(new Error(message));
return;
}
onDrop?.(acceptedFiles, fileRejections, event);
},
...props,
});
return (
<DropzoneContext.Provider
key={JSON.stringify(src)}
value={{ src, accept, maxSize, minSize, maxFiles }}
>
<Button
className={cn(
'relative h-auto w-full flex-col overflow-hidden p-8',
isDragActive && 'outline-none ring-1 ring-ring',
className
)}
disabled={disabled}
type="button"
variant="outline"
{...getRootProps()}
>
<input {...getInputProps()} disabled={disabled} />
{children}
</Button>
</DropzoneContext.Provider>
);
};
const useDropzoneContext = () => {
const context = useContext(DropzoneContext);
if (!context) {
throw new Error('useDropzoneContext must be used within a Dropzone');
}
return context;
};
export type DropzoneContentProps = {
children?: ReactNode;
className?: string;
};
const maxLabelItems = 3;
export const DropzoneContent = ({
children,
className,
}: DropzoneContentProps) => {
const { src } = useDropzoneContext();
if (!src) {
return null;
}
if (children) {
return children;
}
return (
<div className={cn('flex flex-col items-center justify-center', className)}>
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<UploadIcon size={16} />
</div>
<p className="my-2 w-full truncate font-medium text-sm">
{src.length > maxLabelItems
? `${new Intl.ListFormat('en').format(
src.slice(0, maxLabelItems).map((file) => file.name)
)} and ${src.length - maxLabelItems} more`
: new Intl.ListFormat('en').format(src.map((file) => file.name))}
</p>
<p className="w-full text-wrap text-muted-foreground text-xs">
Drag and drop or click to replace
</p>
</div>
);
};
export type DropzoneEmptyStateProps = {
children?: ReactNode;
className?: string;
};
export const DropzoneEmptyState = ({
children,
className,
}: DropzoneEmptyStateProps) => {
const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext();
if (src) {
return null;
}
if (children) {
return children;
}
let caption = '';
if (accept) {
caption += 'Accepts ';
caption += new Intl.ListFormat('en').format(Object.keys(accept));
}
if (minSize && maxSize) {
caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`;
} else if (minSize) {
caption += ` at least ${renderBytes(minSize)}`;
} else if (maxSize) {
caption += ` less than ${renderBytes(maxSize)}`;
}
return (
<div className={cn('flex flex-col items-center justify-center', className)}>
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<UploadIcon size={16} />
</div>
<p className="my-2 w-full truncate text-wrap font-medium text-sm">
Upload {maxFiles === 1 ? 'a file' : 'files'}
</p>
<p className="w-full truncate text-wrap text-muted-foreground text-xs">
Drag and drop or click to upload
</p>
{caption && (
<p className="text-wrap text-muted-foreground text-xs">{caption}.</p>
)}
</div>
);
};

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
/**
* Hook to detect when files are being dragged into the browser window
* @returns isDragging - true when files are being dragged over the window
*/
export function useFileDrag() {
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
let dragCounter = 0;
const handleDragEnter = (e: DragEvent) => {
// Only detect file drags
if (e.dataTransfer?.types.includes("Files")) {
dragCounter++;
if (dragCounter === 1) {
setIsDragging(true);
}
}
};
const handleDragLeave = () => {
dragCounter--;
if (dragCounter === 0) {
setIsDragging(false);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = () => {
dragCounter = 0;
setIsDragging(false);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("dragover", handleDragOver);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragover", handleDragOver);
window.removeEventListener("drop", handleDrop);
};
}, []);
return isDragging;
}