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を置いて仮組みする。
さらに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に生成させたぞい
ここで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>
);
}
まあ普通にコンポーネントにしろって感じやね。次回は最終調整
この記事が気に入ったらサポートをしてみませんか?