React ドラッグ&ドロップでファイルを選択する
React ファイル選択のコンポーネントを作成するで、ファイル選択のコンポーネントを作成しました。
このコンポーネントに対して、ドラッグ&ドロップでファイルを選択できる機能を追加します。
ドラッグ時の挙動などのスタイルが未設定の状態ですが、最低限の機能は以下になります。
import { useCallback, useRef, useState } from 'react';
const App = () => {
const [files, setFiles] = useState<File[]>([]);
const attachRef = useRef<HTMLInputElement>(null);
const handleInpuFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files == null) return;
const files = Array.from(e.target.files);
setFiles((current) => current.concat(files));
if (attachRef.current) attachRef.current.value = '';
}, []);
const onDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
}, []);
const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); // デフォルトのイベントをキャンセル
e.stopPropagation(); // 親要素へのイベント伝播をキャンセル
const files = Array.from(e.dataTransfer.items)
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null); // ドロップしたファイルを配列で取得
setFiles((current) => current.concat(files)); // stateに追加
}, []);
return (
<>
<div>
{files.map((f) => (
<div key={f.name}>{f.name}</div>
))}
</div>
<div>
<input type="button" value="参照" onClick={() => attachRef.current?.click()}></input>
<input type="file" style={{ display: 'none' }} ref={attachRef} multiple onChange={handleInpuFileChange}></input>
</div>
<div
style={{ border: 'dashed 1px #000', padding: 20 }}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
ここにファイルをドロップしてください。
</div>
</>
);
};
export default App;
解説
以下のイベントを定義します。
- onDragEnter
- マウスカーソルが要素に入ったときに発生
- onDranLeave
- マウスカーソルが要素から外れた時に発生
- onDragOver
- マウスカーソルが要素内にあるときに発生
- onDrop
- ドロップしたときに発生
onDrop
以外は特に処理はしていませんが、デフォルトのイベントをキャンセルするために設定しています。
例えばテキストファイルをドロップすると新しいタブでファイルが開かれてしまいますが、そのような動作を無効にします。
onDrop
イベントにて、e.dataTransfer.items
でドロップしたオブジェクトを取得できます。
ファイルがドロップされたとは限らないので、getAsFile
でファイルとして扱えるもののみ追加しています。
スタイルの追加
現状ではドラッグしたときにスタイルが変わらないため、どこにドロップすればいいのかがわかりずらいです。
また要素外にドロップするとデフォルトのイベント(テキストファイルの場合は新しいタブでファイルが開かれる)が発生してしまいます。
以下のように要素がドラッグ中かどうかの状態を管理して、要素内にドラッグしたら要素の背景色を変更します。
import { useCallback, useRef, useState } from 'react';
const App = () => {
const [files, setFiles] = useState<File[]>([]);
const attachRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState<number>(0);
const handleInpuFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files == null) return;
const files = Array.from(e.target.files);
setFiles((current) => current.concat(files));
if (attachRef.current) attachRef.current.value = ''; // 内部的なファイルの選択状態をクリア
}, []);
const onDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging((current) => current + 1);
}, []);
const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging((current) => current - 1);
}, []);
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
}, []);
const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.items)
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
setFiles((current) => current.concat(files));
}, []);
return (
<>
<div>
{files.map((f) => (
<div key={f.name}>{f.name}</div>
))}
</div>
<div>
<input type="button" value="参照" onClick={() => attachRef.current?.click()}></input>
<input type="file" style={{ display: 'none' }} ref={attachRef} multiple onChange={handleInpuFileChange}></input>
</div>
<div
style={{ border: 'dashed 1px #000', padding: 20 }}
style={{ border: 'dashed 1px #000', padding: 20, backgroundColor: dragging > 0 ? '#ccc' : undefined }}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
ここにファイルをドロップしてください。
</div>
</>
);
};
export default App;
dragging
という変数を定義し、要素内に入ったら+1、外れたら-1をして、1以上の場合は要素内にいるという判定になります。
onDragEnter
とonDragLeave
は要素内の子要素に入った場合も発生してしまうため、bool
値での管理ではなく数値で管理をしています。
次に、ドラッグ可能かどうかをカーソルで判断できるようにします。
まずは要素外にドロップを不可とするため、ルート要素などで以下を定義します。
import { useEffect } from 'react';
const onDragEnter = (e: DragEvent) => {
e.preventDefault();
};
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer !== null) {
// ドロップ不可のカーソル
e.dataTransfer.dropEffect = 'none';
}
e.preventDefault();
};
const onDrop = (e: DragEvent) => {
// デフォルトのイベントキャンセル
e.preventDefault();
};
const Main = () => {
useEffect(() => {
window.addEventListener('dragenter', onDragEnter);
window.addEventListener('dragover', onDragOver);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragenter', onDragEnter);
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', onDrop);
};
}, []);
return (
<App />
);
};
export default Main;
次にonDragOver
のイベントで、ドロップ可能のカーソルを設定します。
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}, []);
こうすることにより、要素外のドロップを無効にし、要素へのドロップが可能なのを視覚的に表現することができます。
React クリップボードにコピーしたファイルを貼り付けるで、クリップボードにコピーしたファイルを貼り付けて、選択する方法を紹介しています。