在 React app 實作拖曳上傳區塊(drag and drop file uploader)

如題,之後的專案可能會用到這類元件,趁有空時搓一個練手感。成品與全部原始碼請參考這裡

實作思路

關於 DragAndDrop.tsx

import { useState } from 'react';
import cn from 'classnames';
import './DragAndDrop.css';

type Props = {
  onUploadFile: (files: FileList) => void;
};

function DragAndDrop({ onUploadFile }: Props) {
  /* State */
  const [isHighlight, setIsHighlight] = useState<boolean>(false);

  /* Function */
  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault(); // Prevent navigation.
    setIsHighlight(false);
    onUploadFile(e.dataTransfer.files);
  };
  const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault(); // Prevent navigation.
    setIsHighlight(true);
  };
  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault(); // Prevent navigation.
    setIsHighlight(true);
  };
  const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault(); // Prevent navigation.
    setIsHighlight(false);
  };

  /* Main */
  return (
    <div
      onDrop={handleDrop}
      onDragEnter={handleDragEnter}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      className={cn(
        'DragAndDrop_dropZone',
        isHighlight && 'DragAndDrop_highlight'
      )}
    >
      <p>Drag and drop the files here</p>
    </div>
  );
}

export default DragAndDrop;

這個元件最重要的任務就是透過 Html5 的 Drag and Drop Api 來判斷「使用者是否已經將檔案拖進有效區域」(即 css .DragAndDrop_dropZone 標記起來的元件)。若「有」則透過 css .DragAndDrop_highlight 來變化元件外觀,提示使用者可以放手(讓檔案落下)。

至於使用者丟進來的檔案要如何處理,則全部交給 props.onUploadFile 來負責。


關於樣式控制:兩組 css 命名的原則是「元件名稱+區塊意圖」,目的是盡量降低樣式撞名的機率。工程師可自行決定要在 app 的全域樣式檔案中處理此元件的樣式:

.DragAndDrop_dropZone {
  border: 2px dashed #ccc;
  /* 下略 */
}

或是由每一個引用此元件的親元件透過 css module 來管理外觀:

:local(.parent_class) :global(.DragAndDrop_dropZone) {
  background-color: navy;
  /* 下略 */
}

關於 App.tsx

import { useState, useRef } from 'react';
import Container from './Container';
import DragAndDrop from './DragAndDrop';
import classes from './App.module.css';

function App() {
  /* State */
  const [files, setFiles] = useState<File[]>([]);
  const [errors, setErrors] = useState<File[]>([]);
  const dialogRef = useRef<HTMLDialogElement | null>(null);

  /* Function */
  const onOpenDialog = (error: File[]) => {
    if (!error.length) return;
    setErrors(error);
    dialogRef.current?.showModal();
  };
  const onCloseDialog = () => {
    dialogRef.current?.close();
    setErrors([]);
  };
  // do whatever you want with the files
  const onUploadFile = (files: FileList) => {
    const result: File[] = [];
    const error: File[] = [];
    for (const f of files) {
      // for example, only accept png files
      if (f.type === 'image/png') {
        result.push(f);
      } else {
        error.push(f);
      }
    }
    setFiles(result);
    onOpenDialog(error);
  };
  const onDeleteFile = (index: number) => {
    setFiles((prevFiles) => {
      const newFiles = [...prevFiles];
      newFiles.splice(index, 1);
      return newFiles;
    });
  };

  /* Main */
  return (
    <section className={classes.wrapper}>
      <Container>
        <h1>React drag and drop file uploader demo</h1>
        <DragAndDrop onUploadFile={onUploadFile} />
        {files.length > 0 && (
          <ul>
            {files.map((file, i) => (
              <li key={i}>
                {file.name}
                <button onClick={() => onDeleteFile(i)}>Delete</button>
              </li>
            ))}
          </ul>
        )}
      </Container>
      <dialog ref={dialogRef}>
        <p>Following files are not upload:</p>
        <ul>
          {errors.map((err, i) => (
            <li key={i}>{err.name}</li>
          ))}
        </ul>
        <button onClick={onCloseDialog}>close</button>
      </dialog>
    </section>
  );
}

export default App;

重點:透過 onUploadFile 來判斷允許上傳的檔案。以本篇範例而言,上傳規則就是「僅能使用 image/png 但不限制檔案數量。而當使用者提供了 png 以外的格式時,跳出對話框提示使用者哪些檔案沒有被採納」。可參考下方畫面截圖:

file upload error hint

負責記錄檔案的局部變數 files 可根據規格需求拿去做二次加工,或是拿去打 api 等等⋯⋯。

搞定 🔨

參考文件