ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 빠르게 시작하는 드래그 앤 드롭
    React.js & Next.js 2023. 3. 26. 20:15

    prerequisites

    •  typescript
      • interface
    • react.js
      • useRef
      • useState
      • Props
    • inline-styling
    • conditional render

    전체 코드

    import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
    
    interface IFileTypes {
      id: number;
      object: File;
    }
    
    const basicInputStyle = {
      width: '100%',
      height: '200px',
      border: '2px solid',
      borderRadius: '10px',
      cursor: 'pointer',
      transition: '0.12s ease-in',
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
    };
    
    const Test = () => {
      const [files, setFiles] = useState<IFileTypes[]>([]);
      const [isDragging, setIsDragging] = useState<boolean>(false);
    
      const fileId = useRef<number>(0);
      const dragRef = useRef<HTMLLabelElement>(null);
    
      const onSubmit = useCallback(() => {
        console.log('-- onsubmit files:', files);
      }, [files]);
    
      useEffect(() => {
        console.log('-- files changed:', files);
      }, [files]);
    
      const onChangeFiles = useCallback(
        (e: ChangeEvent<HTMLInputElement> | any): void => {
          let selectFiles: File[] = [];
          let tempFiles: IFileTypes[] = [...files];
    
          if (e.type === 'drop') {
            selectFiles = e.dataTransfer.files;
          } else {
            selectFiles = e.target.files;
          }
    
          for (const file of selectFiles) {
            tempFiles = [
              ...tempFiles,
              {
                id: fileId.current++,
                object: file,
              },
            ];
          }
    
          setFiles(tempFiles);
        },
        [files, setFiles]
      );
    
      const handleFilterFile = useCallback(
        (id: number): void => {
          setFiles(files.filter((file: IFileTypes) => file.id !== id));
        },
        [files, setFiles]
      );
    
      const handleDragIn = useCallback((e: DragEvent): void => {
        e.preventDefault();
      }, []);
    
      const handleDragOut = useCallback((e: DragEvent): void => {
        e.preventDefault();
    
        setIsDragging(false);
      }, []);
    
      const handleDragOver = useCallback((e: DragEvent): void => {
        e.preventDefault();
    
        if (e.dataTransfer!.files) {
          setIsDragging(true);
        }
      }, []);
    
      const handleDrop = useCallback(
        (e: DragEvent): void => {
          e.preventDefault();
    
          if (dragRef.current) onChangeFiles(e);
          setIsDragging(false);
        },
        [dragRef, onChangeFiles]
      );
    
      const initDragEvents = useCallback((): void => {
        if (dragRef.current !== null) {
          dragRef.current.addEventListener('dragenter', handleDragIn);
          dragRef.current.addEventListener('dragleave', handleDragOut);
          dragRef.current.addEventListener('dragover', handleDragOver);
          dragRef.current.addEventListener('drop', handleDrop);
        }
      }, [dragRef, handleDragIn, handleDragOut, handleDragOver, handleDrop]);
    
      const resetDragEvents = useCallback((): void => {
        if (dragRef.current !== null) {
          dragRef.current.removeEventListener('dragenter', handleDragIn);
          dragRef.current.removeEventListener('dragleave', handleDragOut);
          dragRef.current.removeEventListener('dragover', handleDragOver);
          dragRef.current.removeEventListener('drop', handleDrop);
        }
      }, [dragRef, handleDragIn, handleDragOut, handleDragOver, handleDrop]);
    
      useEffect(() => {
        initDragEvents();
    
        return () => resetDragEvents();
      }, [initDragEvents, resetDragEvents]);
    
      return (
        <div>
          <label
            ref={dragRef}
            style={
              isDragging
                ? {
                    ...basicInputStyle,
                    background: '#8cff69',
                  }
                : {
                    ...basicInputStyle,
                    background: '#ffb7b7',
                  }
            }
          >
            <input
              type='file'
              id='fileUpload'
              onChange={onChangeFiles}
              style={{ width: '100%', border: 'none' }}
              aria-label='file upload'
            />
          </label>
    
          <div style={{ marginTop: '3%' }}>
            {files.length > 0 &&
              files.map((file: IFileTypes) => {
                return (
                  <div
                    key={file.id}
                    style={{
                      display: 'flex',
                      justifyContent: 'space-between',
                      width: '100%',
                      border: '1px solid',
                      marginBottom: 2,
                      padding: 2,
                    }}
                  >
                    <span style={{ marginRight: 10 }}>{file.object.name}</span>
                    <span onClick={() => handleFilterFile(file.id)} style={{ cursor: 'pointer' }}>
                      X
                    </span>
                  </div>
                );
              })}
          </div>
    
          <button type='button' onClick={onSubmit}>
            제출
          </button>
        </div>
      );
    };
    
    export default Test;
    

    설명

    IFileTypes

    interface IFileTypes {
      id: number;
      object: File;
    }
    
    • 인터페이스로 선언하여 사용할 파일 인터페이스입니다
    • 파일은 HTML에 의해 제공되는 파일을 사용합니다
    /** Provides information about files and allows JavaScript in a web page to access their content. */
    interface File extends Blob {
        readonly lastModified: number;
        readonly name: string;
        readonly webkitRelativePath: string;
    }
    

    basicInputStyle

    const basicInputStyle = {
      width: '100%',
      height: '200px',
      border: '2px solid',
      borderRadius: '10px',
      cursor: 'pointer',
      transition: '0.12s ease-in',
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
    };
    
    
    • Input 스타일링 코드입니다
    • 스타일드 컴포넌트 등 추가적인 CSS-in-JS를 사용하는 것 대신, 인라인 스타일을 사용하여 코드 이용에 편의를 높였습니다.

    useState

      const [files, setFiles] = useState<IFileTypes[]>([]);
      const [isDragging, setIsDragging] = useState<boolean>(false);
    
      const fileId = useRef<number>(0);
      const dragRef = useRef<HTMLLabelElement>(null);
    

    files:

    전체 파일을 관리할 객체입니다

    isDragging:

    드래그 중인 상황을 캐치할 boolean 타입의 객체입니다

    fileId:

    IFileTypes 인터페이스를 통해 파일을 관리하게 되는데, fileId를 동적으로 추가하기 위한 useRef입니다

    dragRef:

    드래그 앤 드롭 이벤트를 캐치하여 처리할 useRef입니다. DOM 조작을 위해 사용합니다

    Handler

    onChangeFiles

    const onChangeFiles = useCallback(
        (e: ChangeEvent<HTMLInputElement> | any): void => {
          let selectFiles: File[] = [];
          let tempFiles: IFileTypes[] = [...files];
    
          if (e.type === 'drop') {
            selectFiles = e.dataTransfer.files;
          } else {
            selectFiles = e.target.files;
          }
    
          for (const file of selectFiles) {
            tempFiles = [
              ...tempFiles,
              {
                id: fileId.current++,
                object: file,
              },
            ];
          }
    
          setFiles(tempFiles);
        },
        [files, setFiles]
      );
    
    • 파일 변화를 감지하여 files state에 삽입 삭제할 실제 비즈니스 로직입니다
    • 드래그 앤 드롭의 드롭 이벤트 또는 file input 클릭으로 파일을 삽입할 수 있습니다
    • 따라서 이벤트 타입은 drop인 경우와 그 외의 경우를 나눠서 처리하였습니다
    • 이후 for of 문을 통해 순회하며 선택된 각 파일에 IFileTypes 인터페이스에 맞게 id 값을 추가합니다

    handleFilterFile

      const handleFilterFile = useCallback(
        (id: number): void => {
          setFiles(files.filter((file: IFileTypes) => file.id !== id));
        },
        [files, setFiles]
      );
    
    • 클릭 이벤트를 바탕으로 인덱싱 된 id 값에 해당하는 파일을 기존 배열에서 제거합니다
      const handleDragIn = useCallback((e: DragEvent): void => {
        e.preventDefault();
      }, []);
    
      const handleDragOut = useCallback((e: DragEvent): void => {
        e.preventDefault();
    
        setIsDragging(false);
      }, []);
    
      const handleDragOver = useCallback((e: DragEvent): void => {
        e.preventDefault();
    
        if (e.dataTransfer!.files) {
          setIsDragging(true);
        }
      }, []);
    
      const handleDrop = useCallback(
        (e: DragEvent): void => {
          e.preventDefault();
    
          if (dragRef.current) onChangeFiles(e);
          
          setIsDragging(false);
        },
        [dragRef, onChangeFiles]
      );
    
      const initDragEvents = useCallback((): void => {
        if (dragRef.current !== null) {
          dragRef.current.addEventListener('dragenter', handleDragIn);
          dragRef.current.addEventListener('dragleave', handleDragOut);
          dragRef.current.addEventListener('dragover', handleDragOver);
          dragRef.current.addEventListener('drop', handleDrop);
        }
      }, [dragRef, handleDragIn, handleDragOut, handleDragOver, handleDrop]);
    
      const resetDragEvents = useCallback((): void => {
        if (dragRef.current !== null) {
          dragRef.current.removeEventListener('dragenter', handleDragIn);
          dragRef.current.removeEventListener('dragleave', handleDragOut);
          dragRef.current.removeEventListener('dragover', handleDragOver);
          dragRef.current.removeEventListener('drop', handleDrop);
        }
      }, [dragRef, handleDragIn, handleDragOut, handleDragOver, handleDrop]);
    
      useEffect(() => {
        initDragEvents();
    
        return () => resetDragEvents();
      }, [initDragEvents, resetDragEvents]);
    

    event handler

    • 우리는 dragRef를 통해 총 4가지의 이벤트를 핸들링하게 됩니다

    dragenter :

    선택된 영역 안에 드래그가 들어온 경우

    dragleave:

    선택된 영역 밖으로 드래그가 나간 경우

      const handleDragOut = useCallback((e: DragEvent): void => {
        e.preventDefault();
    
        setIsDragging(false);
      }, []);
    

    드래그가 유효하지 않은 상황임을 나타내기 위해 isDragging useState를 false로 변경합니다.

    dragover:

    드래그가 유효한 영역(선택한 영역) 안에 들어온 경우

      const handleDragOver = useCallback((e: DragEvent): void => {
        e.preventDefault();
    
        if (e.dataTransfer!.files) {
          setIsDragging(true);
        }
      }, []);
    

    드래그가 유효한 상황임을 나타내기 위해 isDragging useState를 true로 변경합니다.

    drop:

    선택된 영역 안으로 드래그를 드롭한 경우

    const handleDrop = useCallback(
        (e: DragEvent): void => {
          e.preventDefault();
    
          if (dragRef.current) {
            onChangeFiles(e);
          }
          setIsDragging(false);
        },
        [dragRef, onChangeFiles]
      );
    

    dragRef.current 에 유효할 경우 onChangeFiles 이벤트를 호출합니다.

    dragenter 이벤트와 dragover 이벤트의 차이는 무엇인가요?

    The dragover and dragenter events are both part of the HTML Drag and Drop API and are fired when an element is being dragged over another element. However, there are some differences between them:

    1. Triggering: The dragover event is fired continuously as long as the dragged element is over the target element, while the dragenter event is only fired once when the dragged element first enters the target element.
    2. Default behavior: By default, the dragover event allows the dragged element to be dropped on the target element, while the dragenter event does not allow it. To enable dropping on the target element, you need to call event.preventDefault() on the dragover event.
    3. Use case: The dragenter event is typically used to apply some visual feedback to the target element, such as changing the background color or adding a border, to indicate that the element can be dropped on it. The dragover event is typically used to determine whether the dragged element can be dropped on the target element, and to update the visual feedback accordingly.

    전체적으로 드래그 앤터 이벤트는 끌린 요소가 대상 요소에 들어갈 때 사용자에게 시각적 피드백을 제공하는 데 사용되는 반면, 드래그 오버 이벤트는 대상에 요소를 떨어뜨릴 수 있는지 여부를 결정하고 그에 따라 시각적 피드백을 제공하는 데 사용됩니다. 따라서 두 이벤트 모두에 대한 컨트롤이 필요합니다.

    useEffect

      useEffect(() => {
        initDragEvents();
    
        return () => resetDragEvents();
      }, [initDragEvents, resetDragEvents]);
    

    마운트 시에 initDragEvents 함수를 호출합니다. 이후 언마운트 시에 클린업 함수를 모아놓은 resetDragEvents 함수를 호출합니다.

    render

    return (
        <div>
          <label
            ref={dragRef}
            style={
              isDragging
                ? {
                    ...basicInputStyle,
                    background: '#8cff69',
                  }
                : {
                    ...basicInputStyle,
                    background: '#ffb7b7',
                  }
            }
          >
            <input
              type='file'
              id='fileUpload'
              onChange={onChangeFiles}
              style={{ width: '100%', border: 'none' }}
              aria-label='file upload'
            />
          </label>
    
          <div style={{ marginTop: '3%' }}>
            {files.length > 0 &&
              files.map((file: IFileTypes) => {
                return (
                  <div
                    key={file.id}
                    style={{
                      display: 'flex',
                      justifyContent: 'space-between',
                      width: '100%',
                      border: '1px solid',
                      marginBottom: 2,
                      padding: 2,
                    }}
                  >
                    <span style={{ marginRight: 10 }}>{file.object.name}</span>
                    <span onClick={() => handleFilterFile(file.id)} style={{ cursor: 'pointer' }}>
                      X
                    </span>
                  </div>
                );
              })}
          </div>
    
          <button type='button' onClick={onSubmit}>
            제출
          </button>
        </div>
      );
    

    결과 보기

    댓글

Designed by Tistory.