Files
llink/js/desktop/src/hooks/use-file-input.ts
T
2026-06-11 12:08:34 -07:00

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 },
};
}