For

2021.12.26

React で複数ファイルの入力をドラッグ&ドロップで複数回受け付けられ、フォーム送信時までファイルをHTML上に保持できるフォームの実装



通常の type='file' の input 要素では、一度ファイルを選択して再度ファイルを選択すると、追加で選択ではなく前回選択したファイルが新しく選択したファイルで上書きされます。これを追加で選択できるようにし、かつドラッグ&ドロップでの入力も受け付けられるインプットフィールドを作成します。

実装コード

実装したコードを先に貼っておきます。
クラスをたくさん付与していますが、すべて TailwindCSS のクラスによるスタイリング用なので、とくに JavaScript の実装面には関与しません。
また、インポートしているものはファイル名から拡張子だけ返す関数、スタイリング用の <div> 要素を返すコンポーネントのみです。
Ref と Event については、それぞれ Ref であること、Event であること、それら以外を受け取ることがないことから any で処理しています。

TypeScript_____sample_____import * as React from 'react'
import { GetFileExtension } from '../../libraries/GetFileExtension'
import Divider from '../common/Divider'
import Dividers from '../common/Dividers'

type _Props = {
    id: string
    name: string
    format: 'jpg' | 'eps'
    className?: string
}

const MultipleImagesDragAndDropInputField = ({ id, name, format, className }: _Props): JSX.Element => {
    const [isSelected, setIsSelected] = React.useState(false)
    const [isDragOver, setIsDragOver] = React.useState(false)
    const inputElem = React.useRef<any>()
    const inputForDataTransfer = React.useRef<any>()
    const unselectedTextElem = React.useRef<any>()
    const selectedTextElem = React.useRef<any>()
    const onChange = (e: any) => {
        e.stopPropagation()
        e.preventDefault()
        const _dataTransfer = new DataTransfer()
        Array.prototype.forEach.call(inputForDataTransfer.current.files, (file) => {
            _dataTransfer.items.add(file)
        })
        Array.prototype.forEach.call(inputElem.current.files, (file) => {
            _dataTransfer.items.add(file)
        })
        inputElem.current.files = _dataTransfer.files
        inputForDataTransfer.current.files = _dataTransfer.files
        setIsDragOver(false)
    }
    const onDrop = (e: any) => {
        e.stopPropagation()
        e.preventDefault()
        const _dataTransfer = new DataTransfer()
        Array.prototype.forEach.call(inputElem.current.files, (file) => {
            if (GetFileExtension(file.name) == (format as string)) _dataTransfer.items.add(file)
        })
        Array.prototype.forEach.call(e.dataTransfer.files, (file) => {
            if (GetFileExtension(file.name) == (format as string)) _dataTransfer.items.add(file)
            selectedTextElem.current.innerHTML += `<p>${file.name}</p>`
        })
        inputElem.current.files = _dataTransfer.files
        inputForDataTransfer.current.files = _dataTransfer.files
        setIsSelected(true)
        setIsDragOver(false)
    }

    const onDragOver = (e: any) => {
        e.stopPropagation()
        e.preventDefault()
        setIsDragOver(true)
    }

    const onDragLeave = (e: any) => {
        e.stopPropagation()
        e.preventDefault()
        setIsDragOver(false)
    }
    return (
        <div
            className={`${className ? className : ''} ${
                isDragOver ? 'bg-gray-200' : 'bg-gray-100'
            } rounded-8 border border-gray-200 hover:bg-gray-200 transition-all duration-300 text-14`}
        >
            <label
                htmlFor={id}
                className={`block w-100p p-15 hover:cursor-pointer relative flex justify-center items-center`}
                onDragOver={onDragOver}
                onDragLeave={onDragLeave}
                onDrop={onDrop}
            >
                <Dividers className='w-100p h-100p items-center'>
                    <Divider base={6} className='h-100p flex justify-center items-center'>
                        <p ref={unselectedTextElem} className={`w-100p text-center text-14 relative`}>
                            <span>
                                ファイルをドラッグ&ドロップ
                                <br />
                                またはクリックして選択
                            </span>
                        </p>
                    </Divider>
                    <Divider base={6}>
                        <p ref={unselectedTextElem} className={`${isSelected ? 'hidden' : ''} w-100p text-center text-gray-400 border-l border-gray-200`}>
                            ファイルが選択されていません
                        </p>
                        <div ref={selectedTextElem} className={`${isSelected ? '' : 'hidden'} text-gray-400 border-l border-gray-200 pl-30`}></div>
                    </Divider>
                </Dividers>
            </label>
            <input ref={inputElem} type='file' id={id} name={name} className={`hidden`} onChange={onChange} accept={format} multiple={true} />
            <input ref={inputForDataTransfer} type='file' className={`hidden`} multiple={true} />
        </div>
    )
}

export default MultipleImagesDragAndDropInputField


type='file' でファイルを選択したときに起きること

type='file' の input 要素では、基本的にはクリックして開いたダイアログでファイルを選択することになります。さらに、ドラッグ&ドロップでファイルの入力を受け付けるように実装することも可能です。
ただ、ダイアログによるファイル選択とドラッグ&ドロップによる入力ではデータ保持のプロセスが異なるため、まずは「ダイアログによるファイル選択」と「ドラッグ&ドロップ」をしたときのファイルがどのように保持されるかについて確認します。

ダイアログによるファイル選択

ダイアログによるファイル選択をした場合、HTMLInputElement.files (以下 files と呼びます)で入力されたファイルが保持されます。
files に保持されたファイルはフォーム送信時まで特別な操作なく保持できます。
また、files でファイルを保持している状態で再度ダイアログを開いてファイルを選択すると、それまでに保持していた files の内容を破棄し、新しく選択したファイルを files に保持します。
そのため、1回目にファイルAを選択したあと、ファイルBも選択したい場合は、2回目のファイル選択時にファイルAとファイルBを選択すると files に両ファイルが入力された状態になります。

ドラッグ&ドロップによるファイル入力

ドラッグ&ドロップによるファイル入力では、ダイアログによるファイル選択のように直接 files にファイルが保持されません。
この場合は onDrop イベントの Event.dataTransfer でファイルが保持され、手動で input 要素の files 属性にファイルをセットする必要があります。

実装

ダイアログによるファイル選択での複数回ファイル入力

上記のとおり、ダイアログによるファイル選択では複数回ファイル入力すると新しく選択したものしか files に保持されないため、ファイル入力を受け付ける input 要素とは別に、データ保持用の input 要素を配置し、入力されたファイルを都度データ保持用 input 要素にためていくようにします。
onChange イベントでファイル選択時の処理を記述します。冒頭に貼り付けたコードのうち、以下の部分が該当します。

TypeScript_____onChangeイベント_____    const onChange = (e: any) => {
        e.stopPropagation()
        e.preventDefault()
        const _dataTransfer = new DataTransfer()
        Array.prototype.forEach.call(inputForDataTransfer.current.files, (file) => {
            _dataTransfer.items.add(file)
        })
        Array.prototype.forEach.call(inputElem.current.files, (file) => {
            _dataTransfer.items.add(file)
        })
        inputElem.current.files = _dataTransfer.files
        inputForDataTransfer.current.files = _dataTransfer.files
        setIsDragOver(false)
    }


const _dataTransfer = new DataTransfer() で DataTransfer のインスタンスを作ります。
DataTransferでは DataTransfer.items.add(file) とすることでファイルを保持できるため、処理の流れとしては、データ保持用 input 要素の files から DataTransfer のインスタンスにファイルを移し、続いて入力されたファイルを DataTransfer のインスタンスに追加、最後に DataTransfer のインスタンスで保持しているファイルをデータ保持用の input 要素の files に戻します。
こうすることで、複数回ファイル入力を受け付けられるようになります。
今回の実装では入力されたファイルをクリアする機能はありませんが、inputElem.current.filesinputForDataTransfer.current.files を null にすることで実装可能なので、必要に応じて実装してください。

ドラッグ&ドロップによるファイル入力

ファイルがドロップされた場合、上述のとおり Event.dataTransfer でファイルが保持されているため、ダイアログによるファイル選択の実装を以下のように少し変えることで実装可能です。

TypeScript_____onDropイベント_____    const onDrop = (e: any) => {
        e.stopPropagation()
        e.preventDefault()
        const _dataTransfer = new DataTransfer()
        Array.prototype.forEach.call(inputElem.current.files, (file) => {
            if (GetFileExtension(file.name) == (format as string)) _dataTransfer.items.add(file)
        })
        Array.prototype.forEach.call(e.dataTransfer.files, (file) => {
            if (GetFileExtension(file.name) == (format as string)) _dataTransfer.items.add(file)
            selectedTextElem.current.innerHTML += `<p>${file.name}</p>`
        })
        inputElem.current.files = _dataTransfer.files
        inputForDataTransfer.current.files = _dataTransfer.files
        setIsSelected(true)
        setIsDragOver(false)
    }


少し違うところは、ファイルの拡張子を確認しているところです。
input 要素でのファイル選択時の拡張子は accept 属性で制御可能ですが、ドラッグ&ドロップではドロップする時点の拡張子はユーザーの操作次第となるので、受け付けたい拡張子以外は DataTransfer インスタンスに追加しないという条件を加えています。

こうすることで、ダイアログによるファイル選択とドラッグ&ドロップによるファイル入力で、複数回入力を受け付けることができます。