Reactでプレビュー機能付きの画像アップローダー(ドラッグもあり)

04/02/2020

個人開発のLaravelサイトをReact化してみたのだけども、
よくあるプレビュー機能付きの画像アップローダー
みたいなのが以外と面倒だったのでちょいと書いてみます。

サンプル

宣伝がてらですが、参考ページは下記です。

https://wordstacks.nocebo.jp/posts/create

“ここにファイルをドロップ”の部分です。

べっ、別に投稿してもいいんだからね!

とりあえず抜粋だけど全文

全部入ってるとわかりにくい気がしたので
色々省略してますがこんな感じになりました。

雰囲気Reactと雰囲気Typescriptなので
細かい事はご愛嬌。

ちょっとした参考になれば幸いです。

型付けとか特に適当
anyだっていいじゃないか、動くんだもの

import React, { useState, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { PostActions } from "@/module/post";
import styled from "styled-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";

// ---------------------------
const Root = styled.div`
    /* スタイル省略 */
`;
const Preview = styled.div`
    /* スタイル省略 */
`;
const UI = styled.div`
    /* スタイル省略 */
    .area[data-drag="true"] {
        /* ドラッグ時のスタイル */
    }
`;
// ---------------------------
const Images: React.FC = props => {
    const dispatch = useDispatch();
    const [isDrag, setIsDrag] = useState(false);
    const [images, setImages] = useState([]);

    useEffect(() => {
        dispatch(PostActions.updateImages(images);
    }, [images]);

    // ファイル選択&ドラッグからの処理
    const handleChangeFile = files => {
        const merge: Array<Object> = [];
        (async () => {
            await Promise.all(
                Object.keys(files).map(async item => {
                    const file: File = files[Number(item)];
                    const reader: FileReader = await readerOnloadend(file);
                    merge.push({ file: file, previewUrl: reader.result });
                })
            );
            setImages([...images, ...merge]);
        })();
    };

    // await reader.onloadend
    const readerOnloadend = async (data): Promise<FileReader> => {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onloadend = () => {
                resolve(reader);
            };
            reader.readAsDataURL(data);
        });
    };

    // 削除
    const remove = (index: number) => {
        images.splice(index, 1);
        setImages([...images]);
    };

    // クリア
    const clear = () => {
        setImages([]);
    };

    // ドラッグ状態
    const dragState = (e, state) => {
        e.preventDefault();
        setIsDrag(state);
    };

    // プレビュー画像表示
    const previews = Array.from(images || []).map(
        (image: any, index: number) => {
            return (
                <div className="element" key={index}>
                    <img src={image.previewUrl} alt="プレビュー画像" />
                    <button
                        onClick={(e: any) => {
                            remove(index);
                        }}
                    >
                        <FontAwesomeIcon icon={["fas", "minus"]} />
                    </button>
                </div>
            );
        }
    );

    // EDIT時にすでに登録済みのstrageからの画像をfile(blob)に変換
    const { records, isRecord } = useSelector((state: any) => state.posts);
    useEffect(() => {
        if (!isRecord || !records[0]) return;
        dispatch(PostActions.isRecord(false));
        (async () => {
            const recordImages: Array<Object> = [];
            await Promise.all(
                records.map(async item => {
                    const response = await fetch(`/storage/${item.path}`);
                    const arrayBuffer = await response.arrayBuffer();
                    const blob = new Blob([arrayBuffer], {type: "image/jpeg"});
                    const reader: FileReader = await readerOnloadend(blob);
                    recordImages.push({
                        file: new File([blob], "name"),
                        previewUrl: reader.result
                    });
                })
            );
            setImages([...recordImages]);
        })();
    }, [isRecord]);
    // ---------------------------
    return (
        <Root>
            <Preview>{previews}</Preview>
            <UI>
                <div
                    className="area"
                    data-drag={isDrag}
                    onDragEnter={e => {dragState(e, true)}}
                    onDragLeave={e => {dragState(e, false)}}
                    onDragOver={e => {dragState(e, true)}}
                    onDrop={e => {
                        dragState(e, false);
                        handleChangeFile(e.dataTransfer.files);
                    }}
                >
                    <p>ここにファイルをドロップ</p>
                    <span>または</span>
                    <label>
                        ファイルを選択
                        <input
                            type="file"
                            accept="image/*"
                            multiple
                            onChange={e => handleChangeFile(e.target.files)}
                        />
                    </label>
                </div>
                <div className="clear">
                    <button onClick={clear}>クリア</button>
                </div>
            </UI>
        </Root>
    );
};

export default Images;

ハイライト表示効いてない?かも。

あとはちょっとした補足等

handleChangeFile

useEffect(() => {
        dispatch(PostActions.updateImages(images);
}, [images]);

const handleChangeFile = files => {
    const merge: Array<Object> = [];
    (async () => {
        await Promise.all(
            Object.keys(files).map(async item => {
                const file: File = files[Number(item)];
                const reader: FileReader = await readerOnloadend(file);
                merge.push({ file: file, previewUrl: reader.result });
            })
        );
        setImages([...images, ...merge]);
    })();
};

ドラッグとファイルを選択。で使ってるハンドラです。

特にはないけどawaitで読めたらuseEffectでreduxに反映してます

readerOnloadend

const readerOnloadend = async (data): Promise<FileReader> => {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onloadend = () => {
            resolve(reader);
        };
        reader.readAsDataURL(data);
    });
};

await reader.onloadend()

って書きたくなるけどそのままだと出来ないっぽいので
上記な感じにしてみた。

readAsDataURL

readAsDataURLはBlobFileオブジェクトを読めるっぽい。

今回ので言うと
handleChangeFileの時はFile
editの時useEffectでstorageの画像ひっぱってくる時はBlobです。

お?こういう時にジェネリクス使えばいいのかな?
って思ってちょっと型付け試してみたけど、
async使ってどう書けばよいのか、サクッとわからないので諦めた。

creteとeditで使いまわしたい

のでeditの場合に下記のような感じにしました。

const { records, isRecord } = useSelector((state: any) => state.posts);
useEffect(() => {
    if (!isRecord || !records[0]) return;
    dispatch(PostActions.isRecord(false));
    (async () => {
        const recordImages: Array<Object> = [];
        await Promise.all(
            records.map(async item => {
                const response = await fetch(`/storage/${item.path}`);
                const arrayBuffer = await response.arrayBuffer();
                const blob = new Blob([arrayBuffer], { type: "image/jpeg" });
                const reader: FileReader = await readerOnloadend(blob);
                recordImages.push({
                    file: new File([blob], "name"),
                    previewUrl: reader.result
                });
            })
        );
        setImages([...recordImages]);
    })();
}, [isRecord]);

ここが個人的に結構めんどくさいポイントですが
普通にリモートにある画像imgのまま読むだけだと
Laravel側に送った時にupdateが出来ないっぽいのと
ハンドラから反映するimagesの型と合わせないと
下のpreviewsで使いまわせないのでBlob読んでFileにしました。
Blobはバイナリ、らしい。

const previews = Array.from(images || []).map((image: any, index: number) => {
    return (
        <div className="element" key={index}>
            <img src={image.previewUrl} alt="プレビュー画像" />
            <button
                onClick={(e: any) => {
                    remove(index);
                }}
            >
                <FontAwesomeIcon icon={["fas", "minus"]} />
            </button>
        </div>
    );
});

以上です。