122 lines
3.3 KiB
TypeScript
122 lines
3.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
interface UseFileInputOptions {
|
|
onFilesSelected: (files: File[]) => void;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export function useFileInput({
|
|
onFilesSelected,
|
|
enabled,
|
|
}: UseFileInputOptions) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const dragCountRef = useRef(0);
|
|
|
|
// Latest callback in a ref, so the effects below don't re-register when the
|
|
// caller passes a new function each render.
|
|
const onFilesRef = useRef(onFilesSelected);
|
|
useEffect(() => {
|
|
onFilesRef.current = onFilesSelected;
|
|
}, [onFilesSelected]);
|
|
|
|
// Hidden file input element
|
|
useEffect(() => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.multiple = true;
|
|
input.style.display = 'none';
|
|
input.addEventListener('change', () => {
|
|
if (input.files?.length) {
|
|
onFilesRef.current(Array.from(input.files));
|
|
input.value = '';
|
|
}
|
|
});
|
|
document.body.appendChild(input);
|
|
inputRef.current = input;
|
|
return () => {
|
|
document.body.removeChild(input);
|
|
inputRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const openFilePicker = useCallback(() => {
|
|
inputRef.current?.click();
|
|
}, []);
|
|
|
|
// Clipboard paste
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
// Capture phase so file pastes always become attachments — ProseMirror
|
|
// would otherwise inline pasted images as ephemeral blob: URLs. Mixed
|
|
// clipboards (e.g. Excel/Word ship an image rendition alongside the text)
|
|
// must still paste as text, so only file-only pastes are intercepted.
|
|
const handlePaste = (e: ClipboardEvent) => {
|
|
const data = e.clipboardData;
|
|
if (!data) return;
|
|
const files = Array.from(data.files);
|
|
if (files.length === 0 || data.types.includes('text/plain')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onFilesRef.current(files);
|
|
};
|
|
|
|
window.addEventListener('paste', handlePaste, true);
|
|
return () => window.removeEventListener('paste', handlePaste, true);
|
|
}, [enabled]);
|
|
|
|
// Drag and drop handlers
|
|
const onDragOver = useCallback(
|
|
(e: React.DragEvent) => {
|
|
if (!enabled) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
[enabled],
|
|
);
|
|
|
|
const onDragEnter = useCallback(
|
|
(e: React.DragEvent) => {
|
|
if (!enabled) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragCountRef.current++;
|
|
if (dragCountRef.current === 1) setIsDragging(true);
|
|
},
|
|
[enabled],
|
|
);
|
|
|
|
const onDragLeave = useCallback(
|
|
(e: React.DragEvent) => {
|
|
if (!enabled) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragCountRef.current--;
|
|
if (dragCountRef.current === 0) setIsDragging(false);
|
|
},
|
|
[enabled],
|
|
);
|
|
|
|
const onDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
if (!enabled) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragCountRef.current = 0;
|
|
setIsDragging(false);
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (files.length > 0) {
|
|
onFilesRef.current(files);
|
|
}
|
|
},
|
|
[enabled],
|
|
);
|
|
|
|
return {
|
|
openFilePicker,
|
|
isDragging,
|
|
dropZoneProps: { onDragOver, onDragEnter, onDragLeave, onDrop },
|
|
};
|
|
}
|