見出し画像

laravel breeze (react) にavater機能を付ける(5) cropper連携とmodal

続き

さてさて、avatarなのでやっぱcropper.jsというかreact-cropperと連携しときてえな的な。filepond-plugin-image-edit も入ってなかったら入れる

npm install react-cropper  filepond-plugin-image-edit 

cropperの領域を適当に設置

つか、これ前の記事でも書いてるんで、割と今回適当っす。

resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx 

import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';

でreact-cropperを呼びこんで

export default function UpdateProfileInformation({ mustVerifyEmail, status, className = '' }) {
  const { user } = usePage().props.auth;
  const filePondRef = useRef(null); 
  const cropperRef = useRef(null); // Added
  const [imgSrc, setImgSrc] = useState(''); // Added
  const [cropperUrl, setCropperUrl] = useState(null);// Added


// ...
  const crop = () => {
  }

を適当に配置。imgSrcのstateを保存するのと各種refの保存である

そしたらば

<Cropper
  ref={cropperRef}
  src={imgSrc}
  aspectRatio={1}
  viewMode={1}
/>
<PrimaryButton type="button" onClick={crop}>Crop Image</PrimaryButton>

<FilePond
// ...

FilePondの上らへんにCropperを置いて仮組みする。

とりあえずCrop Imageのボタンだけ出来ている

さらにFilePond側で

<FilePond
  ref={filePondRef}
  name="avatar"
  onupdatefiles={files => {
    if (files.length > 0) {
      setData('avatar', files[0].file);
    } else {
      setData('avatar', null);
    }
  }}
  allowMultiple={false}
  acceptedFileTypes={['image/jpeg', 'image/png', 'image/gif']}
  maxFileSize="5MB"
  imageEditEditor={editor}
  {...localeJA}
/>

imageEditEditor でeditor関数を参照するようにする、つわけでここで指定したeditor 関数を作る。本当は諸々メソッドを書いてもいいんだけど今回はopenだけ。最低限openだけは必要。

const editor = {
  open: (file, imageEditCallback) => {
  },
};

そうすると

突然現れる編集できそうなアイコン

編集マークが付くので、押し、ても何も起きない

editorのopen関数を書く

  const editor = {
    open: (file, imageEditCallback) => {
      const objectURL = URL.createObjectURL(file);
      const imageElement = cropperRef.current;
      if (imageElement && imageElement.cropper) {
        imageElement.cropper.replace(objectURL);
      }
    },
  };

cropperのrefからreplace 関数を読んでいる。ここでobjectURL としてローカルblobURLを発生させている。これでeditボタンを押すとcroppingウインドウが出るだろう。

つか、いい加減おっさんのavatarだとテンションが上がらないのでchatgptに生成させたぞい

上にcropper、下にfilepond

ここでCROP IMAGEを押すとcroppingされるようにcrop関数に仕掛けていく

  const crop = () => {
    const imageElement = cropperRef.current;
    if (imageElement && imageElement.cropper) {
      const canvas = imageElement.cropper.getCroppedCanvas();
      canvas.toBlob((blob) => {
        // FilePondの`addFile`メソッドを使用して、編集後のBlobをFilePondインスタンスに追加
        filePondRef.current.addFile(blob);

        // オブジェクトURLをクリア
        if (cropperUrl) {
          URL.revokeObjectURL(cropperUrl); // メモリを開放
          setCropperUrl(null); // cropperUrlの状態をクリア
        }
      }, "image/jpeg");
    }

  };

これでほぼ機能は完成。しかしUIに難があるし、それ以外もいろいろある。

微調整

前にやったのとほとんど変わんねえんだけど、image/jpegの決め打ちをやめるためのstateを1つ作る

export default function UpdateProfileInformation({ mustVerifyEmail, status, className = '' }) {
  const { user } = usePage().props.auth;
  const filePondRef = useRef(null);
  const cropperRef = useRef(null);

  const [imgSrc, setImgSrc] = useState('');
  const [originalFile, setOriginalFile] = useState(null);// Added

openしたときにこの第一引数fileをstateに詰めこむ

  const editor = {
    open: (file, imageEditCallback) => {
      setOriginalFile(file); // ここで入れている
      const objectURL = URL.createObjectURL(file);
      const imageElement = cropperRef.current;
      if (imageElement && imageElement.cropper) {
        imageElement.cropper.replace(objectURL);
      }
    },
  };

で、ハードコードしてるやつをやめて、stateから取ってきて詰める

  const crop = () => {
    const imageElement = cropperRef.current;
    if (imageElement && imageElement.cropper) {
      const canvas = imageElement.cropper.getCroppedCanvas();
      canvas.toBlob((blob) => {
        // FilePondの`addFile`メソッドを使用して、編集後のBlobをFilePondインスタンスに追加
        filePondRef.current.addFile(blob);

        // オブジェクトURLをクリア
        if (cropperUrl) {
          URL.revokeObjectURL(cropperUrl); // メモリを開放
          setCropperUrl(null); // cropperUrlの状態をクリア
        }
      }, originalFile.type); // ここ 
    }

モーダル

画像編集をモーダルに移動する。これはreact-modalを使う

npm install react-modal

からの

import Modal from 'react-modal';

しといて

          {/* 一旦抹消
<Cropper
  ref={cropperRef}
  src={imgSrc}
  aspectRatio={1}
  viewMode={1}
/>
<PrimaryButton type="button" onClick={crop}>Crop Image</PrimaryButton>
*/}

Cropperを一旦抹消するそして

  const [modalIsOpen, setIsOpen] = useState(false);

modalが開いてるのかどうかのstateを作る

  const [cropModalIsOpen, setCropModalIsOpen] = useState(false);

そしたら

      <Modal
        isOpen={cropModalIsOpen}
        onRequestClose={closeCropModal}
        contentLabel="Cropping"
      >

          <div className="my-2">
            <PrimaryButton type="button" onClick={crop}>Crop</PrimaryButton>
          </div>
      </Modal>

とかを定義しておいておく

openで開くように

する

  const editor = {
    open: (file, imageEditCallback) => {
      setOriginalFile(file);
      const objectURL = URL.createObjectURL(file);
      const imageElement = cropperRef.current;
      if (imageElement && imageElement.cropper) {
        imageElement.cropper.replace(objectURL);
      }
      setCropModalIsOpen(true); // Added
    },
  };

そうするとmodalがとりあえずopenするのだが、閉じれないので、閉じたい

モーダルを閉じれるように

とりあえずSecondaryButtonを呼び込んでくる。まあこれはボタンなら何でもいいが

import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';

そしたら閉じるボタンを作る

          <div className="my-2">
            <PrimaryButton type="button" onClick={crop}>Crop</PrimaryButton>
            <SecondaryButton className="ml-2" onClick={closeCropModal}>Cancel</SecondaryButton>
          </div>
      </Modal>

closeCropModal関数を作る

  const closeCropModal = () => {
    setCropModalIsOpen(false)
  }

これでとりあえず閉じれるようにはなる

うるさい警告を止める

これはもうごちゃごちゃ考えず手順通りやる。inertia.jsの場合最上位がappってidが振ってあるから、これを指定する

import React, { useRef, useState, useEffect } from 'react';

useEffectを呼び込んで

  useEffect(() => {
    Modal.setAppElement('#app'); // ここでappを指定
  }, []);

いよいよcropperを移す

一度抹消しておいたものを移動していく

      <Modal
        isOpen={cropModalIsOpen}
        onRequestClose={closeCropModal}
        contentLabel="Cropping"
      >
        <Cropper
          ref={cropperRef}
          src={cropperUrl}
          aspectRatio={1}
          viewMode={1}
        />

        <div className="my-2">
          <PrimaryButton type="button" onClick={crop}>Crop</PrimaryButton>
          <SecondaryButton className="ml-2" onClick={closeCropModal}>Cancel</SecondaryButton>
        </div>
      </Modal>

これで編集ボタンを押すと

…でかい

というわけで前に定義したstyleを持ってくる

        <Cropper
          ref={cropperRef}
          src={cropperUrl}
          aspectRatio={1}
          className="h-1/3 sm:h-1/2 md:h-2/3 lg:h-3/4" // 画面の高さに対する割合で高さを設定
          viewMode={1}
        />
なかなかいいんじゃない?

完了のときとキャンセル処理を全部書く

  const crop = () => {
    const imageElement = cropperRef.current;
    if (imageElement && imageElement.cropper) {
      const canvas = imageElement.cropper.getCroppedCanvas();
      canvas.toBlob((blob) => {
        // FilePondの`addFile`メソッドを使用して、編集後のBlobをFilePondインスタンスに追加
        filePondRef.current.addFile(blob);

        // オブジェクトURLをクリア
        if (cropperUrl) {
          URL.revokeObjectURL(cropperUrl); // メモリを開放
          setCropperUrl(null); // cropperUrlの状態をクリア
        }
      }, originalFile.type);
    }
    setCropModalIsOpen(false); // Added
  };

閉じる方

  const closeCropModal = () => {
    setCropModalIsOpen(false);
    if (cropperUrl) {
      URL.revokeObjectURL(cropperUrl);
      setCropperUrl(null);
    }
    setOriginalFile(null);
  };

ちょっと駆け足すぎたので全文載せときます

import React, { useRef, useState, useEffect } from 'react';
import {
  Link, useForm, usePage, router,
} from '@inertiajs/react';
import { Transition } from '@headlessui/react';
import Avatar from 'react-avatar';

import { FilePond, registerPlugin } from 'react-filepond';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
import FilePondPluginImageEdit from 'filepond-plugin-image-edit';
import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css';

import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import localeJA from 'filepond/locale/ja-ja.js';
import Modal from 'react-modal';
import TextInput from '@/Components/TextInput';
import SecondaryButton from '@/Components/SecondaryButton';
import PrimaryButton from '@/Components/PrimaryButton';
import InputLabel from '@/Components/InputLabel';
import InputError from '@/Components/InputError';

registerPlugin(
  FilePondPluginFileValidateType,
  FilePondPluginImagePreview,
  FilePondPluginFileValidateSize,
  FilePondPluginImageEdit,
);

export default function UpdateProfileInformation({ mustVerifyEmail, status, className = '' }) {
  const { user } = usePage().props.auth;
  const filePondRef = useRef(null);
  const cropperRef = useRef(null);

  const [originalFile, setOriginalFile] = useState(null);
  const [cropperUrl, setCropperUrl] = useState(null);
  const [cropModalIsOpen, setCropModalIsOpen] = useState(false);
  useEffect(() => {
    Modal.setAppElement('#app');
  }, []);

  const {
    data, setData, post, errors, processing, recentlySuccessful, reset,
  } = useForm({
    name: user.name,
    email: user.email,
    avatar: null,
    _method: 'patch',
  });

  const editor = {
    open: (file, imageEditCallback) => {
      setOriginalFile(file);
      const objectURL = URL.createObjectURL(file);
      setCropperUrl(objectURL); // objectURLを状態にセット

      const imageElement = cropperRef.current;
      if (imageElement && imageElement.cropper) {
        imageElement.cropper.replace(objectURL);
      }
      setCropModalIsOpen(true); // Added
    },
  };

  const submit = (e) => {
    e.preventDefault();
    post(route('profile.update'), { onSuccess: () => clearFile() });
  };

  const clearFile = () => {
    if (filePondRef.current) {
      filePondRef.current.removeFile();
    }
    reset('avatar');
  };

  const crop = () => {
    const imageElement = cropperRef.current;
    if (imageElement && imageElement.cropper) {
      const canvas = imageElement.cropper.getCroppedCanvas();
      canvas.toBlob((blob) => {
        // FilePondの`addFile`メソッドを使用して、編集後のBlobをFilePondインスタンスに追加
        filePondRef.current.addFile(blob);

        // オブジェクトURLをクリア
        if (cropperUrl) {
          URL.revokeObjectURL(cropperUrl); // メモリを開放
          setCropperUrl(null); // cropperUrlの状態をクリア
        }
      }, originalFile.type);
    }
    setCropModalIsOpen(false);
  };

  const closeCropModal = () => {
    setCropModalIsOpen(false);
    if (cropperUrl) {
      URL.revokeObjectURL(cropperUrl);
      setCropperUrl(null);
    }
    setOriginalFile(null);
  };

  return (
    <section className={className}>
      <header>
        <h2 className="text-lg font-medium text-gray-900">Profile Information</h2>

        <p className="mt-1 text-sm text-gray-600">
          Update your account's profile information and email address.
        </p>

        <Avatar
          src={route('profile.avatar', { user: user.id, version: 'thumbnail', t: Date.now() })}
          name={user.name}
          size="100"
          round
          maxInitials={1}
        />
      </header>

      <form onSubmit={submit} className="mt-6 space-y-6">
        <div>
          <InputLabel htmlFor="name" value="Name" />

          <TextInput
            id="name"
            className="mt-1 block w-full"
            value={data.name}
            onChange={(e) => setData('name', e.target.value)}
            required
            isFocused
            autoComplete="name"
          />

          <InputError className="mt-2" message={errors.name} />
        </div>

        <div>
          <InputLabel htmlFor="email" value="Email" />

          <TextInput
            id="email"
            type="email"
            className="mt-1 block w-full"
            value={data.email}
            onChange={(e) => setData('email', e.target.value)}
            required
            autoComplete="username"
          />

          <InputError className="mt-2" message={errors.email} />
        </div>

        {mustVerifyEmail && user.email_verified_at === null && (
        <div>
          <p className="text-sm mt-2 text-gray-800">
            Your email address is unverified.
            <Link
              href={route('verification.send')}
              method="post"
              as="button"
              className="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
              Click here to re-send the verification email.
            </Link>
          </p>

          {status === 'verification-link-sent' && (
          <div className="mt-2 font-medium text-sm text-green-600">
            A new verification link has been sent to your email address.
          </div>
          )}
        </div>
        )}

        <div>
          <InputLabel htmlFor="avatar" value="Avatar" />
          <FilePond
            ref={filePondRef}
            name="avatar"
            onupdatefiles={(files) => {
              if (files.length > 0) {
                setData('avatar', files[0].file);
              } else {
                setData('avatar', null);
              }
            }}
            allowMultiple={false}
            acceptedFileTypes={['image/jpeg', 'image/png', 'image/gif']}
            maxFileSize="5MB"
            imageEditEditor={editor}
            {...localeJA}
          />
          <InputError className="mt-2" message={errors.avatar} />
        </div>

        <div className="flex items-center gap-4">
          <PrimaryButton disabled={processing}>Save</PrimaryButton>

          <Transition
            show={recentlySuccessful}
            enter="transition ease-in-out"
            enterFrom="opacity-0"
            leave="transition ease-in-out"
            leaveTo="opacity-0"
          >
            <p className="text-sm text-gray-600">Saved.</p>
          </Transition>
        </div>
      </form>

      <Modal
        isOpen={cropModalIsOpen}
        onRequestClose={closeCropModal}
        contentLabel="Cropping"
      >
        <Cropper
          ref={cropperRef}
          src={cropperUrl}
          aspectRatio={1}
          className="h-1/3 sm:h-1/2 md:h-2/3 lg:h-3/4" // 画面の高さに対する割合で高さを設定
          viewMode={1}
        />

        <div className="my-2">
          <PrimaryButton type="button" onClick={crop}>Crop</PrimaryButton>
          <SecondaryButton className="ml-2" onClick={closeCropModal}>Cancel</SecondaryButton>
        </div>
      </Modal>

    </section>
  );
}

まあ普通にコンポーネントにしろって感じやね。次回は最終調整


この記事が気に入ったらサポートをしてみませんか?