usePageFileDrop.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
  2. interface UsePageFileDropOptions {
  3. /** Called when files are dropped that pass the extension filter. */
  4. onFiles: (files: File[]) => void;
  5. /** Called when a drop event had files but none matched `extensions`. */
  6. onRejected?: () => void;
  7. /** Lowercase extensions including the dot (e.g. ['.3mf']). Omit to accept all. */
  8. extensions?: string[];
  9. /** Disable the drop zone entirely (e.g. when the user lacks upload permission). */
  10. disabled?: boolean;
  11. }
  12. interface UsePageFileDropResult {
  13. isDraggingOver: boolean;
  14. dragHandlers: {
  15. onDragOver: (e: DragEvent) => void;
  16. onDragEnter: (e: DragEvent) => void;
  17. onDragLeave: (e: DragEvent) => void;
  18. onDrop: (e: DragEvent) => void;
  19. };
  20. }
  21. /**
  22. * Page-wide drag-and-drop file zone. Survives the three cancel paths that
  23. * dragLeave alone misses: drag-out-of-window, Escape during drag, and drag
  24. * release outside any drop target. Each fix is captured by a separate test
  25. * case in usePageFileDrop.test.tsx.
  26. */
  27. export function usePageFileDrop({
  28. onFiles,
  29. onRejected,
  30. extensions,
  31. disabled = false,
  32. }: UsePageFileDropOptions): UsePageFileDropResult {
  33. const [isDraggingOver, setIsDraggingOver] = useState(false);
  34. const onFilesRef = useRef(onFiles);
  35. const onRejectedRef = useRef(onRejected);
  36. const extensionsRef = useRef(extensions);
  37. useEffect(() => { onFilesRef.current = onFiles; }, [onFiles]);
  38. useEffect(() => { onRejectedRef.current = onRejected; }, [onRejected]);
  39. useEffect(() => { extensionsRef.current = extensions; }, [extensions]);
  40. const handleDragOver = useCallback((e: DragEvent) => {
  41. if (disabled) return;
  42. e.preventDefault();
  43. if (e.dataTransfer.types.includes('Files')) {
  44. setIsDraggingOver(true);
  45. }
  46. }, [disabled]);
  47. const handleDragEnter = useCallback((e: DragEvent) => {
  48. if (disabled) return;
  49. e.preventDefault();
  50. if (e.dataTransfer.types.includes('Files')) {
  51. setIsDraggingOver(true);
  52. }
  53. }, [disabled]);
  54. const handleDragLeave = useCallback((e: DragEvent) => {
  55. if (disabled) return;
  56. e.preventDefault();
  57. const wrapper = e.currentTarget as Node;
  58. const next = e.relatedTarget as Node | null;
  59. if (!next || !wrapper.contains(next)) {
  60. setIsDraggingOver(false);
  61. }
  62. }, [disabled]);
  63. const handleDrop = useCallback((e: DragEvent) => {
  64. if (disabled) return;
  65. e.preventDefault();
  66. setIsDraggingOver(false);
  67. const all = Array.from(e.dataTransfer.files);
  68. if (all.length === 0) return;
  69. const exts = extensionsRef.current;
  70. const matched = exts && exts.length > 0
  71. ? all.filter(f => exts.some(ext => f.name.toLowerCase().endsWith(ext)))
  72. : all;
  73. if (matched.length > 0) {
  74. onFilesRef.current(matched);
  75. } else {
  76. onRejectedRef.current?.();
  77. }
  78. }, [disabled]);
  79. useEffect(() => {
  80. if (!isDraggingOver) return;
  81. const reset = () => setIsDraggingOver(false);
  82. const handleKey = (e: KeyboardEvent) => {
  83. if (e.key === 'Escape') reset();
  84. };
  85. document.addEventListener('drop', reset);
  86. document.addEventListener('dragend', reset);
  87. document.addEventListener('keydown', handleKey);
  88. return () => {
  89. document.removeEventListener('drop', reset);
  90. document.removeEventListener('dragend', reset);
  91. document.removeEventListener('keydown', handleKey);
  92. };
  93. }, [isDraggingOver]);
  94. return {
  95. isDraggingOver,
  96. dragHandlers: {
  97. onDragOver: handleDragOver,
  98. onDragEnter: handleDragEnter,
  99. onDragLeave: handleDragLeave,
  100. onDrop: handleDrop,
  101. },
  102. };
  103. }