投稿者は初心者です。あたたかい目で見守ってくださいますと幸いです。
前回
はじめに
前回に引き続き「複数枚ステージングした画像をトリミングするロジック」の解説、今回はPart2です。
いよいよ実際のトリミング処理周りに触れていこうかと思います。
コンポーネントの追加
ステージングされた画像を実際に表示するコンポーネント、画像を追加する用のボックスコンポーネント、この二つを設置してみましょう。
src直下にこちらの二つを追加してください。
↓ステージングされた画像を表示するコンポーネント(画像一つ一つ)
import { Avatar, Box, IconButton } from '@mui/material';
import { useBreakPoint } from '../hooks';
import { useState } from 'react';
import { ImagePopper } from './ImagePopper';
import { MoreVert } from '@mui/icons-material';
import { StagingImageProps } from '../types';
export const StagingImage = ({ url }: StagingImageProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const breakpoint = useBreakPoint();
const handleOpen = (e: React.MouseEvent<HTMLElement>): void => {
setAnchorEl(e.currentTarget);
setIsOpen(true);
};
return (
<Box
sx={{
position: 'relative',
width: breakpoint === 'xs' ? 'calc(50% - 10px)' : 'calc(25% - 10px)',
height: 'calc(100% - 10px)',
borderRadius: '5px',
overflow: 'hidden',
margin: '0 0 10px 10px',
}}
>
<Avatar
src={url}
variant="square"
sx={{
width: '100%',
height: '100%',
'&:hover + .vertButton': {
opacity: 1,
},
}}
/>
<IconButton
onClick={handleOpen}
className="vertButton"
sx={{
position: 'absolute',
top: '5px',
right: '5px',
color: '#fff',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
opacity: 0,
'&:hover': {
opacity: 1,
},
}}
>
<MoreVert />
</IconButton>
<ImagePopper
index={index}
isOpen={isOpen}
setIsOpen={setIsOpen}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
/>
</Box>
);
};
↓画像を追加する用のボックスコンポーネント
import { Box } from '@mui/material';
import { useBreakPoint, useUploadImages } from '../hooks';
import { AddPhotoAlternateOutlined } from '@mui/icons-material';
import { useRef } from 'react';
export const AddImageBox = () => {
const breakpoint = useBreakPoint();
const { isDragging, setIsDragging, handleFileSelect, handleFileDrop } =
useUploadImages();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
return (
<>
<Box
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
onClick={handleUploadClick}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
gap: '20px',
width:
breakpoint === 'xs'
? 'calc(50% - 10px)'
: breakpoint === 'sm'
? 'calc(25% - 10px)'
: 'calc(25% - 10px)',
height: 'calc(100% - 10px)',
padding: '10px',
margin: '0 0 10px 10px',
borderRadius: '5px',
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: isDragging ? '#ddd' : 'transparent',
'&:hover': {
backgroundColor: '#ddd',
},
}}
>
<AddPhotoAlternateOutlined />
<div style={{ textAlign: 'center' }}>
{isDragging
? '商品画像をここにドロップ'
: 'クリックまたはドラッグで商品画像を追加アップロード'}
</div>
</Box>
<input
type="file"
accept="image/png, image/jpg, image/jpeg, image/webp"
ref={fileInputRef}
style={{
display: 'none',
}}
onChange={handleFileSelect}
/>
</>
);
};
そしてindex.tsでまとめてexportするのをお忘れずに。
(今回は同じcomponents内のファイルから呼び出すのであまり意味はないですが。。。)
export * from './UploadImages';
export * from './StagingImage';
export * from "./AddImageBox"
そうしたらcomponents/UploadImages.tsxでこれらを呼び出します。
破壊的な変更を伴うので、ぜひコピペしてみてください。
import { Box } from '@mui/material';
import { useRef } from 'react';
import { useUploadImages } from '../hooks/useUploadImages';
import { AddPhotoAlternateOutlined } from '@mui/icons-material';
import { StagingImage } from './StagingImage';
import { AddImageBox } from './AddImageBox';
import { useBreakPoint } from '../hooks';
export const UploadImages = () => {
const breakpoint = useBreakPoint();
const {
isDragging,
setIsDragging,
handleFileSelect,
handleFileDrop,
uploadImages,
} = useUploadImages();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
return (
<>
<Box
sx={{
display: 'flex',
justifyContent: 'start',
alignItems: 'start',
flexWrap: 'wrap',
aspectRatio: ['xs'].includes(breakpoint) ? '2/1' : '4/1',
overflowY:
breakpoint === 'xs'
? uploadImages.length >= 2
? 'scroll'
: 'hidden'
: uploadImages.length >= 4
? 'scroll'
: 'hidden',
overflowX: 'hidden',
width: '100%',
padding: '10px 10px 0 0',
border: 'dashed 2px #000',
}}
>
{uploadImages.length === 0 ? (
<Box
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
onClick={handleUploadClick}
sx={{
display: 'flex',
justifyContent: 'start',
alignItems: 'start',
flexWrap: 'wrap',
gap: '10px',
width: 'calc(100% - 10px)',
height: 'calc(100% - 10px)',
margin: '0 0 10px 10px',
cursor: 'pointer',
overflow: 'hidden',
wordBreak: 'break-all',
color: '#000',
backgroundColor: isDragging ? '#ddd' : 'transparent',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: '#ddd',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
width: '100%',
height: '100%',
}}
>
<AddPhotoAlternateOutlined />
<div style={{ textAlign: 'center' }}>
{isDragging
? '商品画像をここにドロップ'
: 'クリックまたはドラッグで商品画像をアップロード'}
</div>
</Box>
</Box>
) : (
<>
{uploadImages.map((image, index) => (
<StagingImage key={index} url={image} />
))}
{uploadImages.length < 8 && <AddImageBox />}
</>
)}
</Box>
<input
type="file"
accept="image/png, image/jpg, image/jpeg, image/webp"
ref={fileInputRef}
style={{
display: 'none',
}}
onChange={handleFileSelect}
/>
</>
);
};
UploadImages.tsxはステージングされた画像を表示する領域で、各画像ごとにindex(配列の何番目の画像であるか)と画像urlを渡しています。
import { Box, Paper, Popper } from '@mui/material';
import { ImagePopperProps } from '../types/ImagePopperProps';
import { useEffect, useRef } from 'react';
export const ImagePopper = ({
index,
isOpen,
setIsOpen,
anchorEl,
setAnchorEl,
setisTrimming,
}: ImagePopperProps) => {
const popperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handlePopperClose = (e: MouseEvent) => {
if (
anchorEl &&
popperRef.current &&
!anchorEl.contains(e.target as Node) &&
!popperRef.current.contains(e.target as Node)
) {
setAnchorEl(null);
setIsOpen(false);
}
};
document.addEventListener('click', handlePopperClose);
return () => {
document.removeEventListener('click', handlePopperClose);
};
}, [anchorEl]);
return (
<Popper
open={isOpen}
anchorEl={anchorEl}
placement="bottom-end"
ref={popperRef}
>
<Paper
sx={{
width: '150px',
maxWidth: '25vw',
padding: '5px',
borderRadius: '10px',
backgroundColor: '#eee',
}}
>
<Box
onClick={() => setisTrimming(true)}
sx={{
width: '100%',
margin: '0 auto',
padding: '7px 0 7px 3px',
borderRadius: '5px',
cursor: 'pointer',
color: '#000',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: '#ddd',
},
}}
>
トリミング
</Box>
<Box
sx={{
width: '100%',
margin: '0 auto',
padding: '7px 0 7px 3px',
borderRadius: '5px',
cursor: 'pointer',
color: '#f00',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: '#ddd',
},
}}
>
削除
</Box>
</Paper>
</Popper>
);
};
ImagePopper.tsxに渡す型はこちら。
export interface ImagePopperProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
anchorEl: HTMLElement | null;
setAnchorEl: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
setAnchorEl: (anchorEl: HTMLElement | null) => void;
}
すると...?
画像をステージングできるようになりました!レイアウト的にも完璧ですね!
Providerの修正
前回の仕様ではうまく動作しないことが判明しました。。。
providerを以下のように修正します。
import { ReactNode, createContext, useState } from 'react';
import { ProviderProps } from '../types';
export const Context = createContext<ProviderProps | null>(null);
export const ContextProvider = ({ children }: { children: ReactNode }) => {
const initialZooms: number[] = [];
const initialCrops: { x: number, y: number }[] = [];
for (let i = 0; i < 8; i++) {
initialCrops.push({ x: 0, y: 0 });
}
for (let i = 0; i < 8; i++) {
initialZooms.push(1);
}
const [isDragging, setIsDragging] = useState<boolean>(false); // ステージングエリアに画像をドラッグしているか
const [uploadImages, setUploadImages] = useState<string[]>([]); // トリミング画像のプレビュー用配列
const [originalImages, setOriginalImages] = useState<string[]>([]); // トリミング前のオリジナル画像配列
const [crops, setCrops] = useState<{ x: number; y: number }[]>(initialCrops); // 画像のトリミング位置の配列
const [zooms, setZooms] = useState<number[]>(initialZooms); // 画像の拡大率の配列
const contextValue = {
isDragging,
setIsDragging,
uploadImages,
setUploadImages,
originalImages,
setOriginalImages,
crops,
setCrops,
zooms,
setZooms,
};
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
};
最初にcropsを{x: 0, y: 0}、zoomsを1で初期化しておくと、初回読み込み時にそのindex番目のcrop位置やzoom値を参照できるので便利です。
画像をトリミングできるようにする
いよいよステージングされた任意の画像を選択してトリミングする機能の実装です。
まずトリミングに関する関数をまとめた新しいhooksとその型を用意します。
import { useContext } from "react";
import { Context } from "../provider/Context";
import { HandleCropChangeProps, HandleCropCompleteProps, HandleDrawImageProps, HandleGetCropSizeProps, HandleMediaLoadedProps, handleSetImageProps, HandleTrimmingCompleteProps, HandleZoomChangeProps, UseTrimmingProps } from "../types";
import { Size } from "react-easy-crop";
export const useTriming = (): UseTrimmingProps => {
const context = useContext(Context);
if (!context) {
throw new Error('Context is not provided');
}
const {
uploadImages,
setUploadImages,
originalImages,
setCrops,
crops,
zooms,
setZooms,
} = context;
const handleSetImage = ({ index }: handleSetImageProps): void => {
// 元画像を取得
const image = new Image();
image.src = originalImages[index];
// 擬似的なキャンバスをとコンテクストの生成
const canvas = document.createElement("canvas");
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error("2Dコンテキストの取得に失敗しました");
return;
}
// 画像が読み込まれると処理スタート
image.onload = () => {
// キャンバスのサイズを指定
const canvasSize = Math.min(image.width, image.height);
canvas.width = canvasSize;
canvas.height = canvasSize;
// 描画範囲を計算
const startX = (image.width - canvasSize) / 2;
const startY = (image.height - canvasSize) / 2;
// 元画像の中央を切り抜いて描画
ctx.drawImage(
// 元画像
image,
// 切り抜く範囲
startX,
startY,
canvasSize,
canvasSize,
// 描画先のキャンバスの中央に描画するための調整
0,
0,
canvasSize,
canvasSize
);
};
}
// クロッピング位置を確定させる関数(移動量の確定時に発火)
const handleCropComplete = ({ index, croppedAreaPixels, canvasRef }: HandleCropCompleteProps): void => {
const image = new Image();
image.src = originalImages[index];
image.onload = () => {
handleDrawImage({ image, croppedAreaPixels, canvasRef });
};
}
// canvasタグにプレビュー結果を描画する関数(描画対象のcanvasタグをrefで受け取る)
const handleDrawImage = ({ image, croppedAreaPixels, canvasRef }: HandleDrawImageProps) => {
const canvas = canvasRef.current;
if (!canvas) {
console.error("キャンバスの取得に失敗しました");
return;
}
canvas.width = croppedAreaPixels.width;
canvas.height = croppedAreaPixels.height;
const ctx = canvas.getContext('2d'); // canvasタグに画像を生成するためのオブジェクト(ctx)を取得
if (!ctx) {
console.error("2Dコンテキストの取得に失敗しました");
return;
}
ctx.drawImage(
image, // 呼び出し元から渡された画像です
croppedAreaPixels.x, // 左上始点x
croppedAreaPixels.y, // 左上始点y
croppedAreaPixels.width, // 描画する画像の横幅
croppedAreaPixels.height, // 描画する画像の縦幅
0, // 描画先x座標
0, // 描画先y座標
croppedAreaPixels.width, // 描画する横幅
croppedAreaPixels.height // 描画する縦幅
);
}
// Cropperが画像を読み込んだ時に発火する関数
// 画像の比率を取得し、stateに格納。
const handleMediaLoaded = ({ mediaSize, setImageDimensions }: HandleMediaLoadedProps) => {
setImageDimensions({ width: mediaSize.width, height: mediaSize.height });
};
// 読み込んだ画像のwidth, heightのうち小さいにより合わせて返す関数。
const handleGetCropSize = ({ width, height }: HandleGetCropSizeProps): Size => {
// 画像の縦幅横幅の内小さい方をcropSizeに設定することで、画面に対して最大のズーム領域を設定可能
const minSize = Math.min(width, height);
return { width: minSize, height: minSize };
};
// トリミング書く定時に発火する関数
const handleTrimmingComplete = ({ index, canvasRef }: HandleTrimmingCompleteProps) => {
// 新しい配列を作成
const newUploadImages = [...uploadImages];
// canvasRef からデータURLを取得
const canvas = canvasRef.current;
if (!canvas) {
console.error("キャンバスの取得に失敗しました");
return;
}
const dataUrl = canvas.toDataURL();
// 配列要素を更新
newUploadImages[index] = dataUrl;
// 更新した配列を設定
setUploadImages(newUploadImages);
};
// トリミング座標(crops)変更時に発火する関数
const handleCropChange = ( {index, crop}: HandleCropChangeProps) => {
console.log(crops)
const newCrops = [...crops];
newCrops[index] = crop;
setCrops(newCrops);
}
// ズーム(zooms)変更時に発火する関数
const handleZoomChange = ({ index, zoom }: HandleZoomChangeProps) => {
const newZooms = [...zooms];
newZooms[index] = zoom;
setZooms(newZooms);
}
return {
handleSetImage,
handleCropComplete,
handleDrawImage,
handleMediaLoaded,
handleGetCropSize,
handleTrimmingComplete,
handleCropChange,
handleZoomChange,
}
}
export interface UseTrimmingProps {
handleSetImage: ({ index }: SetImageProps) => void;
handleCropComplete: ({ index, croppedAreaPixels, canvasRef }: HandleCropCompleteProps) => void;
handleDrawImage: ({ index, croppedAreaPixels, canvasRef }: HandleDrawImageProps) => void;
handleMediaLoaded: ({ mediaSize, setImageDimensions }: HandleMediaLoadedProps) => void;
handleGetCropSize: ({ imageDimensions }: HandleGetCropSizeProps) => { width: number; height: number };
handleTrimmingComplete: ({ index, canvasRef }: HandleTrimmingCompleteProps) => void;
handleCropChange: ({ index, crop }: HandleCropChangeProps) => void;
handleZoomChange: ({ index, zoom }: HandleZoomChangeProps) => void;
}
export interface handleSetImageProps {
index: number;
}
export interface HandleCropCompleteProps {
index: number;
croppedAreaPixels: { x: number; y: number; width: number; height: number };
canvasRef: React.RefObject<HTMLCanvasElement>;
}
export interface HandleDrawImageProps {
image: HTMLImageElement;
croppedAreaPixels: { x: number; y: number; width: number; height: number };
canvasRef: React.RefObject<HTMLCanvasElement>;
}
export interface HandleMediaLoadedProps {
mediaSize: { width: number; height: number };
setImageDimensions: React.Dispatch<React.SetStateAction<{ width: number; height: number }>>;
}
export interface HandleGetCropSizeProps {
width: number;
height: number;
};
export interface HandleTrimmingCompleteProps {
index: number;
canvasRef: React.RefObject<HTMLCanvasElement>;
}
export interface HandleCropChangeProps {
index: number;
crop: { x: number; y: number };
}
export interface HandleZoomChangeProps {
index: number;
zoom: number;
}
そしてトリミングに用いるモーダルウィンドウを実装します。
componentsに以下のコンポーネントを追加します。
import { Box, Button, IconButton, Modal, Tooltip } from "@mui/material"
import { TrimmingProps } from "../types/TrimmingProps"
import { Close } from "@mui/icons-material"
import { useEffect, useRef, useState } from "react";
import Cropper from "react-easy-crop";
import { useTriming, useUploadImages } from "../hooks";
import { blue } from "@mui/material/colors";
export const Trimming = ({ index, isTrimming, setIsTrimming }: TrimmingProps) => {
const [imageDimensions, setImageDimensions] = useState<{ width: number, height: number }>({ width: 0, height: 0 });
const { originalImages, crops, zooms } = useUploadImages();
const {
handleSetImage,
handleCropComplete,
handleMediaLoaded,
handleGetCropSize,
handleTrimmingComplete,
handleCropChange,
handleZoomChange,
} = useTriming();
const canvasRef = useRef<HTMLCanvasElement>(null);
const ASPECT_RATIO = 1/1;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isTrimming) {
// Escキー押下でトリミングウィンドウを閉じる
setIsTrimming(false);
} else if (event.key === "Enter" && isTrimming) {
// Enterキー押下でトリミング確定
handleTrimmingComplete({ index, canvasRef });
setIsTrimming(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [index, canvasRef, handleTrimmingComplete]);
useEffect(() => {
handleSetImage({ index });
}, []);
return (
<Modal
open={isTrimming}
onClose={() => setIsTrimming(false)}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
overflow: "hidden",
backgroundColor: "#000",
}}
>
<Tooltip title="キャンセル (Esc)" placement='bottom'>
<IconButton
onClick={() => setIsTrimming(false)}
sx={{
zIndex: 100,
position: "absolute",
top: "4%",
right: "2%",
width: "fit-content",
height: "fit-content",
color: "#fff",
backgroundColor: "rgba(0, 0, 0, 0.5)",
borderRadius: "50%",
}}
>
<Close />
</IconButton>
</Tooltip>
<Cropper
image={originalImages[index]}
crop={crops[index]}
zoom={zooms[index]}
minZoom={1}
maxZoom={4}
aspect={ASPECT_RATIO}
onCropChange={(crop) => handleCropChange({ index, crop })}
onCropComplete={(_, croppedAreaPixels) => handleCropComplete({ index, croppedAreaPixels, canvasRef })}
onZoomChange={(zoom) => handleZoomChange({ index, zoom })}
onMediaLoaded={(mediaSize) => handleMediaLoaded({ mediaSize, setImageDimensions })}
cropSize={handleGetCropSize(imageDimensions)}
classes={{containerClassName: "container", cropAreaClassName: "crop-area", mediaClassName: "media"}}
showGrid
/>
<canvas
ref={canvasRef}
style={{
position: "absolute",
bottom: "2%",
left: "5%",
width: "150px",
aspectRatio: "1/1",
maxWidth: "20vw",
}}
/>
<Box
sx={{
position: "absolute",
bottom: "2%",
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "30px",
padding: "10px",
borderRadius: "5px",
backgroundColor: "rgba(0, 0, 0, 0.5)",
}}
>
<Button
variant="contained"
size="large"
onClick={() => {
handleTrimmingComplete({ index, canvasRef })
setIsTrimming(false)
}}
sx={{
backgroundColor: blue[500],
}}
>トリミング (Enter)</Button>
</Box>
</Box>
</Modal>
)
}
Trimming.tsxの型はコチラを使用します。
export interface TrimmingProps {
index: number;
isTrimming: boolean;
setIsTrimming: (isTrimming: boolean) => void;
}
そうしてこれをStagingImage.tsx内で呼び出します。
<Trimming
index={index}
isTrimming={isTrimming}
setIsTrimming={setIsTrimming}
/>
</Box>
);
こうすることでステージングされた画像ごとにトリミング機能が個別で実装されるようになります。
確認してみる
お!...おおお!...おおおおおお!
プレビューもしっかりできてます!個別に画像をトリミングできる!
Providerの提供するuploadImages配列stateとoriginalImages配列stateについて。
前回わざわざ画像URL配列を二つ用意した理由ですが、すべてはこのプレビュー機能で説明がつきます。
トリミングで更新されているURLはuploadImages配列のみで、originalImagesに変更はありません。もしuploadImagesだけだと、
「トリミングを実行」 => 「もう一度トリミング」とする場合、トリミングされた結果から再実行することになるので、どんどん画像が小さくなってしまい、最終的に消えてしまうからです、恐ろしい。なのでトリミングは元の画像情報から行います。
削除機能の実装
ではステージングさせた画像を削除したい場合はどうなるでしょうか?
画像は親からindexという、その画像が何番目の画像であるか、という情報を持っていましたのでそちらを使用して各配列を更新します。
画像のstateを更新する関数をまとめたuseUploadImages.tsxに新たな関数を追加します。
// ステージングされた画像を削除する関数
const handleImageDelete = ({ index, setIsOpen, setAnchorEl }: HandleImageDeleteProps) => {
const newUploadImages = [...uploadImages];
newUploadImages.splice(index, 1);
const newOriginalImages = [...originalImages];
newOriginalImages.splice(index, 1);
const newCrops = [...crops];
newCrops.splice(index, 1);
newCrops.push({x: 0, y: 0});
const newZooms = [...zooms];
newZooms.splice(index, 1);
newZooms.push(1);
setUploadImages(newUploadImages);
setOriginalImages(newOriginalImages);
setCrops(newCrops);
setZooms(newZooms)
setIsOpen(false);
setAnchorEl(null);
};
indexを使って各配列の情報を更新しています。
型は以下の通りです。useUploadImagesProps.d.tsに追記します。
// こちらはuseUploadImagesProps内に追加
export interface useUploadImagesProps {
...
handleImageDelete: ({ index, setIsPopperOpen, setAnchorEl }: HandleImageDeleteProps) => void;
}
export interface HandleImageDeleteProps {
index: number;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setAnchorEl: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
}
またImagePopper.tsxから削除関数をBoxのonClick属性から呼び出すようにします。
<Box
onClick={() => handleImageDelete({ index: index, setAnchorEl: setAnchorEl, setIsOpen: setIsOpen })}
sx={{
width: '100%',
margin: '0 auto',
padding: '7px 0 7px 3px',
borderRadius: '5px',
cursor: 'pointer',
color: '#f00',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: '#ddd',
},
}}
>
削除
</Box>
するとなんとなんと。
できてますね!
これで任意の画像を削除することができました!
おわりに
今回もだいぶ長くなってしまいました。。。まだこういう記事を書くのは慣れていないのですが、ハンズオン形式って大変なんだなあと思う次第です。
Udemyの講師さんなど、ああいったハンズオン講座を作るのはきっと大変な労力が伴うものだと改めて思います。
なので次のシリーズからは完成品を振り返るといった形をとりそう。
次回は疑似的にトリミング結果を送信してこのシリーズを〆ようともいます。ではでは!
今回使用したリポジトリはコチラ!
次回 (最終回)