| 1 | import { useCallback, useRef, useState } from "react"; |
| 2 | |
| 3 | /** Extract image ``File``s from a paste / drop event. |
| 4 | * |
| 5 | * Deliberate behaviour: |
| 6 | * - Only items whose ``kind === "file"`` and ``type`` starts with |
| 7 | * ``image/`` are returned; ``<img>`` tags inside HTML fragments are |
| 8 | * ignored (defending against remote URL fetch + XSS surfaces). |
| 9 | * - Plain text pasted alongside images is *not* consumed by this helper, |
| 10 | * so the caller can still let the textarea receive it naturally. |
| 11 | */ |
| 12 | export function extractImageFilesFromPaste( |
| 13 | event: ClipboardEvent | React.ClipboardEvent, |
| 14 | ): File[] { |
| 15 | const clipboard = (event as ClipboardEvent).clipboardData |
| 16 | ?? (event as React.ClipboardEvent).clipboardData; |
| 17 | if (!clipboard) return []; |
| 18 | const files: File[] = []; |
| 19 | for (const item of Array.from(clipboard.items)) { |
| 20 | if (item.kind !== "file") continue; |
| 21 | if (!item.type.startsWith("image/")) continue; |
| 22 | const file = item.getAsFile(); |
| 23 | if (file) files.push(file); |
| 24 | } |
| 25 | return files; |
| 26 | } |
| 27 | |
| 28 | /** Extract dropped image files, mirroring ``extractImageFilesFromPaste``. */ |
| 29 | export function extractImageFilesFromDrop( |
| 30 | event: DragEvent | React.DragEvent, |
| 31 | ): File[] { |
| 32 | const dt = (event as DragEvent).dataTransfer |
| 33 | ?? (event as React.DragEvent).dataTransfer; |
| 34 | if (!dt) return []; |
| 35 | const files: File[] = []; |
| 36 | for (const item of Array.from(dt.files)) { |
| 37 | if (item.type.startsWith("image/")) files.push(item); |
| 38 | } |
| 39 | return files; |
| 40 | } |
| 41 | |
| 42 | export interface UseClipboardAndDropApi { |
| 43 | /** Whether a drag is currently hovering the drop zone (toggle dragover UI). */ |
| 44 | isDragging: boolean; |
| 45 | onPaste: ( |
| 46 | event: React.ClipboardEvent, |
| 47 | ) => void; |
| 48 | onDragEnter: (event: React.DragEvent) => void; |
| 49 | onDragOver: (event: React.DragEvent) => void; |
| 50 | onDragLeave: (event: React.DragEvent) => void; |
| 51 | onDrop: (event: React.DragEvent) => void; |
| 52 | } |
| 53 | |
| 54 | /** Wire paste + drag-and-drop to a callback. |
| 55 | * |
| 56 | * The hook owns ``isDragging`` state and the refcount that keeps it accurate |
| 57 | * across nested ``dragenter`` / ``dragleave`` events (a known DOM gotcha: the |
| 58 | * text cursor inside a textarea fires ``dragleave`` on entry, flicking the |
| 59 | * highlight off otherwise). */ |
| 60 | export function useClipboardAndDrop( |
| 61 | onImageFiles: (files: File[]) => void, |
| 62 | ): UseClipboardAndDropApi { |
| 63 | const [isDragging, setIsDragging] = useState(false); |
| 64 | const dragDepth = useRef(0); |
| 65 | |
| 66 | const onPaste = useCallback( |
| 67 | (event: React.ClipboardEvent) => { |
| 68 | const files = extractImageFilesFromPaste(event); |
| 69 | if (files.length === 0) return; |
| 70 | // Consume only when an image is actually present; plain-text paste still |
| 71 | // reaches the textarea unmolested. |
| 72 | event.preventDefault(); |
| 73 | onImageFiles(files); |
| 74 | }, |
| 75 | [onImageFiles], |
| 76 | ); |
| 77 | |
| 78 | const onDragEnter = useCallback((event: React.DragEvent) => { |
| 79 | if (!Array.from(event.dataTransfer.types ?? []).includes("Files")) return; |
| 80 | event.preventDefault(); |
| 81 | dragDepth.current += 1; |
| 82 | setIsDragging(true); |
| 83 | }, []); |
| 84 | |
| 85 | const onDragOver = useCallback((event: React.DragEvent) => { |
| 86 | if (!Array.from(event.dataTransfer.types ?? []).includes("Files")) return; |
| 87 | event.preventDefault(); |
| 88 | event.dataTransfer.dropEffect = "copy"; |
| 89 | }, []); |
| 90 | |
| 91 | const onDragLeave = useCallback((event: React.DragEvent) => { |
| 92 | if (!Array.from(event.dataTransfer.types ?? []).includes("Files")) return; |
| 93 | event.preventDefault(); |
| 94 | dragDepth.current = Math.max(0, dragDepth.current - 1); |
| 95 | if (dragDepth.current === 0) setIsDragging(false); |
| 96 | }, []); |
| 97 | |
| 98 | const onDrop = useCallback( |
| 99 | (event: React.DragEvent) => { |
| 100 | dragDepth.current = 0; |
| 101 | setIsDragging(false); |
| 102 | const files = extractImageFilesFromDrop(event); |
| 103 | if (files.length === 0) return; |
| 104 | event.preventDefault(); |
| 105 | onImageFiles(files); |
| 106 | }, |
| 107 | [onImageFiles], |
| 108 | ); |
| 109 | |
| 110 | return { isDragging, onPaste, onDragEnter, onDragOver, onDragLeave, onDrop }; |
| 111 | } |
| 112 |