Chapter 13

Storage:ファイルアップ前に画像の切り抜き

masalib
masalib
2020.12.26に更新

Storageには直接関係ないかもしれないのですが、アプリなどでアバターなどで使う場合に
アスペクト比が重要になります。
縦と横の比率がおかしいと変に伸びたり、縮んたりします。
ユーザーに縦横比を1:1でアップロードしてくださいと注意文言を出しても違う比率でアップされます。クロップ(画像切り抜き)する事で統一します。

クロップ(画像切り抜き)とは

特定の画像から必要な部分たけを取り込む事です。

処理の概要

今までは画像を選択するファイル名が隣にでるだけだったのですが
ファイルを選択するとモーダルが表示されて、画像の切り抜き画面が表示されて処理します。

react-cropperのインストール

$ cd 作業用Folder\ProjectFolder
$ npm install react-cropper

公式のデモはこちらです

https://codesandbox.io/s/wonderful-pine-i7fs3

アッププログラムの修正

前回のソースを修正します

/src/components/UpLoadTest.js
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { storage } from "../firebase";
- import LinearProgress from "@material-ui/core/LinearProgress";
- import Typography from "@material-ui/core/Typography";
- import Box from "@material-ui/core/Box";
+ import {
+   Typography,
+   Box,
+   Button,
+   LinearProgress,
+   Modal,
+   Backdrop
+ } from "@material-ui/core";
+ import Cropper from "react-cropper";
+ import "cropperjs/dist/cropper.css";
+ import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";

+ const useStyles = makeStyles((theme: Theme) =>
+   createStyles({
+     modal: {
+       display: "flex",
+       alignItems: "center",
+       justifyContent: "center"
+     },
+     paper: {
+       backgroundColor: theme.palette.background.paper,
+       border: "2px solid #000",
+       boxShadow: theme.shadows[5],
+       padding: theme.spacing(2, 4, 3)
+     }
+   })
+ );

const UpLoadTest = () => {
  const [image, setImage] = useState("");
  const [imageUrl, setImageUrl] = useState("");
  const [error, setError] = useState("");
  const [progress, setProgress] = useState(100);
+  const [imageName, setImageName] = useState("");
+   const classes = useStyles(); //Material-ui
+   const [cropper, setCropper] = useState();
+   const [open, setOpen] = React.useState(false);
+   const [openCircularProgress, setOpenCircularProgress] = React.useState(false); //処理中みたいモーダル

-   const handleImage = (event) => {
-     const image = event.target.files[0];
-     setImage(image);
-     console.log(image);
-     setError("");
-   };
-     // アップロード処理
-     console.log("アップロード処理");
-     const storageRef = storage.ref("images/test/"); //どのフォルダの配下に入れるかを設定
-     const imagesRef = storageRef.child(image.name); //ファイル名

-     console.log("ファイルをアップする行為");
-     const upLoadTask = imagesRef.put(image);
-     console.log("タスク実行前");

-     upLoadTask.on(
-       "state_changed",
-       (snapshot) => {
-         console.log("snapshot", snapshot);
-         const percent = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
-         console.log(percent + "% done");
-         setProgress(percent);
-       },
-       (error) => {
-         console.log("err", error);
-         setError("ファイルアップに失敗しました。" + error);
-         setProgress(100); //実行中のバーを消す
-       },
-       () => {
-         upLoadTask.snapshot.ref.getDownloadURL().then((downloadURL) => {
-           console.log("File available at", downloadURL);
-           setImageUrl(downloadURL);
-         });
-       }
-     );
-   };


+   const handleImage = (e) => {
+     setError("");
+     try {
+       const image = e.target.files[0];
+       setImageName(image.name); //アップロード時のファイル名で使用
+       e.preventDefault();
+       let files;
+       if (e.dataTransfer) {
+         files = e.dataTransfer.files;
+       } else if (e.target) {
+         files = e.target.files;
+       }
+       const reader = new FileReader();
+       reader.onload = () => {
+         setImage(reader.result);
+       };
+       reader.readAsDataURL(files[0]);
+       setOpen(true);
+       e.target.value = null; //ファイル選択された内容をクリアする(クリアしないと同じファイルが編集できない)
+     } catch (e) {
+       e.target.value = null;
+       setError("画像の切り取りをキャンセルまたは失敗しました");
+       setOpen(false);
+     }
+   };

+   const getCropData = async (e) => {
+     e.preventDefault();
+     if (typeof cropper !== "undefined") {
+       //デフォルトのPNGはファイルサイズが大きいのでjpegにする
+       let imagedata = await cropper.getCroppedCanvas().toDataURL("image/jpeg");
+       //console.log(imagedata); //バイナリーが見たい人は出力すると見れます

+       // アップロード処理
+       console.log("アップロード処理");
+       const storageRef = storage.ref("images/test/"); //どのフォルダの配下に入れるかを設定
+       const imagesRef = storageRef.child(imageName); //ファイル名

+       console.log("ファイルをアップする行為");
+       const upLoadTask = imagesRef.putString(imagedata, "data_url");

+       console.log("タスク実行前");
+       setOpenCircularProgress(true);

+       upLoadTask.on(
+         "state_changed",
+         (snapshot) => {
+           console.log("snapshot", snapshot);
+           const percent =
+             (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
+           console.log(percent + "% done");
+           setProgress(percent);
+         },
+         (error) => {
+           console.log("err", error);
+           setError("ファイルアップに失敗しました。" + error);
+           setProgress(100); //実行中のバーを消す
+           setOpen(false);
+           setOpenCircularProgress(false);
+         },
+         () => {
+           setImageUrl("");
+           upLoadTask.snapshot.ref.getDownloadURL().then((downloadURL) => {
+             console.log("File available at", downloadURL);
+             setImageUrl(downloadURL);
+             setOpen(false);
+             setOpenCircularProgress(false);
+           });
+         }
+       );
+     }
+     return;
+   };

  const handleClose = () => {
    setOpen(false);
  };
  const handleCircularProgressClose = () => {
    setOpenCircularProgress(false);
  };

  return (
    <div>
      upload
      {error && <div variant="danger">{error}</div>}
      <h2>
        <Link to="/Dashboard">Dashboard</Link>
      </h2>
-       <form onSubmit={onSubmit}>
+       <form>
        <input type="file" onChange={handleImage} />
-         <button onClick={onSubmit}>Upload</button>
      </form>
-       {progress !== 100 && <LinearProgressWithLabel value={progress} />}
      {imageUrl && (
        <div>
          <img width="400px" src={imageUrl} alt="uploaded" />
        </div>
      )}
+       <Modal
+         aria-labelledby="transition-modal-title"
+         aria-describedby="transition-modal-description"
+         className={classes.modal}
+         open={open}
+         onClose={handleClose}
+         closeAfterTransition
+         BackdropComponent={Backdrop}
+         BackdropProps={{
+           timeout: 500
+         }}
+       >
+         <div className={classes.paper}>
+           <h2 id="transition-modal-title" style={{ textAlign: "center" }}>画像の切り抜き</h2>
+           <Cropper
+             style={{ height: 400, width: "100%" }}
+             initialAspectRatio={1}
+             aspectRatio={1}
+             preview=".img-preview"
+             src={image}
+             viewMode={1}
+             guides={true}
+             minCropBoxHeight={10}
+             minCropBoxWidth={10}
+             background={false}
+             responsive={true}
+             autoCropArea={1}
+             checkOrientation={false}
+             onInitialized={(instance) => {
+               setCropper(instance);
+             }}
+           />
+           <Button
+             variant="contained"
+             size="large"
+             fullWidth
+             color="primary"
+             className={classes.updateProfileBtn}
+             onClick={getCropData}
+           >
+             選択範囲で反映
+           </Button>

+           <Button
+             variant="contained"
+             size="large"
+             fullWidth
+             className={classes.updateProfileBtn}
+             onClick={handleClose}
+           >
+             キャンセル
+           </Button>
+         </div>
+       </Modal>
+       <Modal
+         className={classes.modal}
+         open={openCircularProgress}
+         onClose={handleCircularProgressClose}
+         closeAfterTransition
+         BackdropComponent={Backdrop}
+         BackdropProps={{
+           timeout: 500
+         }}
+       >
+         <div className={classes.paper} style={{ textAlign: "center" }}>
+           <div>現在処理中です。</div>
+           {progress !== 100 && <LinearProgressWithLabel value={progress} />}
+         </div>
+       </Modal>
    </div>
  );
};

function LinearProgressWithLabel(props) {
  return (
    <Box display="flex" alignItems="center">
      <Box width="100%" mr={1}>
        <LinearProgress variant="determinate" {...props} />
      </Box>
      <Box minWidth={35}>
        <Typography variant="body2" color="textSecondary">{`${Math.round(
          props.value
        )}%`}</Typography>
      </Box>
    </Box>
  );
}
export default UpLoadTest;

ソース補足

  • crop用にnpmを読んでいます。CSSは公式のものを使っています。使わないの選択画面が表示されない。
import Cropper from "react-cropper";
import "cropperjs/dist/cropper.css";
  • 前回と違ってファイルを選択した段階でクロップの編集画面が開きます
    前回はファイルをuseStateにいれていただけ
    今回はファイルを読み込み、クロップの選択画面を表示しています

  • Modalは普通に作ると面倒くさいのでMaterial-uiのModalを使っています。bootstrapでもmodalはあるので好きなUIのツールで対応できます

  • cropを選択されると選択された部分だけの画像を作ります。

let imagedata = await cropper.getCroppedCanvas().toDataURL("image/jpeg");

切り抜いた画像はData URL string形式になっています。

Data URL string形式の例

data:text/plain;base64,5b6p5Y+344GX44G+44GX44Gf77yB44GK44KB44Gn44Go44GG77yB

toDataURLのmimetypeを指定しないとpngになります。pngはファイルサイズが大きくなるのでjpegを指定しています。

  • toDataURL形式は普通のputではアップできないのでputStringでアップしています
- const upLoadTask = imagesRef.put(image);
+ const upLoadTask = imagesRef.putString(imagedata, "data_url");

data_url以外にbase64、base64urlに対応しています。

詳しくは公式ドキュメントを参照

https://firebase.google.com/docs/storage/web/upload-files?hl=ja

結果

終了時点のソース

  • Guestログインした人は以下のアカウントを使ってください
    ID:test@test.com
    pass:test1234

  • 画像の圧縮などはやっていません。