🚀

【Next.js】画像のサイズを調整できるスクロールバーを実装してみたwww

に公開

はじめまして!もんたです。

私もんた、「もんたの森」っていうもんた版イラストやみたいなWebサービスを趣味で開発しているのですが、最近そのもんたの森に新しい機能を実装いたしました!!

この記事では、その実装した機能についてお話しさせていただこうかなと思います。

この記事を読んで僕と同じかけだしエンジニアの個人開発のモチベーションにつながれば幸いです!

あ、そういえばいろいろやらせてもろてます。
よかったら覗いてみてあげてください。

【たまーに描いた絵をアップする X ( Twitter ) 】

https://x.com/monta_no_mori

【最近始めた Instagram 】

https://www.instagram.com/monta_no_mori/

【もんたのLINEスタンプ】

https://store.line.me/stickershop/author/2887587/ja

概要

背景

時は202x年xx月xx日にまで遡る…

👴『なんじゃこのもんたの森っていうサービスは。画像を保存してLINEのプロフィール画像にしたいのに、画像が大きすぎるわい!!』

👴『なんか、こんな感じになっちゃう!画像がデカすぎるんじゃ!』

↓こんなかんじ
スクリーンショット 2024-09-12 21.01.41.jpg

🐶「おじいさん。ありがとう。つまり、画像のサイズを自由に調整できるようにしたいってことだね!確かにその機能はめちゃくちゃ便利だね!やってみるよ!」

こうしてもんたの戦いが始まった…

スケジュール管理も行ったよ🐶

Notionが大好きで、個人の開発もNotionで管理するようにしているのですが、今回の実装もしっかりスケジュール管理しながら進めました!(暇人すぎる)

https://monta-database.notion.site/436e17902a4843149eb1051e5c8a4b04?pvs=74

もんたの森について

これ見たらわかりやすいよ

https://qiita.com/Maminumemonta0706/items/9e8632698fb6a8329b8c#アプリの概要

きたないPR

汚いコードですが参考にどうぞ。本当に汚いです。本当です…

https://github.com/Hiroto0706/shin-monta-no-mori/pull/29

https://github.com/Hiroto0706/shin-monta-no-mori/pull/30

どんな機能を実装したか

今回実装した機能について軽ーく説明します。

機能としてはめちゃくちゃシンプルで、画像のサイズを画像下のスライドバーを操作することで調整できるというものです。

画像サイズが100%の時(デフォルト)
スクリーンショット 2024-09-13 9.43.51.jpg

画像のサイズが50%の時
スクリーンショット 2024-09-13 9.44.03.jpg

自分の好みの画像サイズに調整した後、画像のダウンロード or コピーをすると指定したサイズで画像をダウンロード or コピーすることができます。

以下の画像は画像サイズを50%にして、コピーした画像になります。
画像のサイズがちょうどいい感じに小さくなってLINEのアイコンとかに設定する時楽そうですね!

image.png

設計

今回の実装にあたって要件定義と詳細設計を行いましたので、それについて話そうと思います。

私生成AI大好きエンジニアなので、要件定義も詳細設計も生成AIを駆使して作成いたしました。

要件定義

https://www.notion.so/monta-database/d66a6bad04b149bfa34528411c9620d7

生成AIとの会話

https://chatgpt.com/share/a34e7f5a-9017-41aa-a1a3-76dd477acc4b

具体的にどのように作成したか

設計の過程はめちゃくちゃ簡単です。

初めに生成AIに「もんたの森の説明」と「実装したい内容の要件」を伝え、要件定義の生成をお願いするだけです。

生成AIが生成した内容に誤りがあれば、その都度修正のプロンプトを投げ、自分のイメージに近い要件定義が出来上がるまで続けるだけです。簡単ですね!!

コード解説

ざっくりとどんなことを実装したのか書いていきます!

画像のサイズを調整するスクロールバーの実装

はじめはスクロールバーの実装ですね。

画像下のこれです。
スクリーンショット 2024-09-13 19.17.51.jpg

全体のコードは以下になります。

    const DetailImage: React.FC<Props> = ({ illustration }) => {
    
    // いろんなコード
    
    const [size, setSize] = useState(100);

    // いろんなコード
  
    <div className="my-2 flex justify-between items-center resize-bar">
      <label className="w-[20%] min-w-[60px] max-w-[70px] flex justify-between pr-2 text-lg">
        <span>{size}</span>
        <span className="text-gray-400">%</span>
      </label>
      <input
        className="w-[80%] rounded-full"
        type="range"
        min="10"
        max="100"
        step="10"
        value={size}
        onChange={handleSliderChange}
        // sizeは10%が最低値のため -10 している。また、100 / 90 は 90% を 100% に正規化している
        style={{
          background: `linear-gradient(to right, #17a34a ${
            (size - 10) * (100 / 90)
          }%, #E5E7EB ${(size - 10) * (100 / 90)}%)`,
        }}
      />
    </div>
    
    // いろんなコード
    
    }

詳しく説明していきます。

以下はスクロールバーを表示しているinputタグです。
valueにsizeというstateを渡しています。

<input
    className="w-[80%] rounded-full"
    type="range"
    min="10"
    max="100"
    step="10"
    value={size}
    onChange={handleSliderChange}
    // sizeは10%が最低値のため -10 している。また、100 / 90 は 90% を 100% に正規化している
    style={{
      background: `linear-gradient(to right, #17a34a ${
        (size - 10) * (100 / 90)
      }%, #E5E7EB ${(size - 10) * (100 / 90)}%)`,
    }}
/>

個人的に詰まったのが、min=10, max=100の時のstyleです。

ここですね。

    // sizeは10%が最低値のため -10 している。また、100 / 90 は 90% を 100% に正規化している
    style={{
      background: `linear-gradient(to right, #17a34a ${
        (size - 10) * (100 / 90)
      }%, #E5E7EB ${(size - 10) * (100 / 90)}%)`,
    }}

これ何やってるのかというと、min=10, max=100の時に、styleを以下のように実装してしまうと、スクロールバーを一番左に移動させたとしても、sizeの最低値が10%なので、下記画像のようになってしまうんです。

style={{
  background: `linear-gradient(to right, #17a34a ${size}%, #E5E7EB ${size}%)`,
}}

スクリーンショット 2024-09-13 19.28.19.jpg

なので、ここでは正規化というものを行い、上記のような問題が起こらないようにしています。

正規化とは、『比較や分析を容易にするために、データの単位やスケールを共通の基準に整えること』を指します。

参考:

https://atmarkit.itmedia.co.jp/ait/articles/2110/07/news027.html

つまり、mix=10%, max=100%を0%~100%のスケールで表現できるように調整してるということ。

具体的には以下のようにして正規化を行なっています。

(size - 10) * (100 / 90)
  1. size - 10としている理由は、sizeの最低値が10%であるため、sizeから10を引くことで0~90の範囲で値とをるようにしています。

  2. 100 / 90では1で0~90にシフトした値を0~100のスケールにするための係数です。90はもとの最大値の範囲なのに対して、100が新しい最大値の範囲になります。

仮にsizeが100の時、この式は(100-10)*(100/90)で100%を返します。
一方で、sizeが10の時だと(10-10)*(100/90)で0を返します。

このようにして、mix=10, max=100の時でも、問題なくスライダーのCSSが表示されるように正規化しています。

調整後の画像をダウンロード or コピーする実装

続いて、調整したsizeをダウンロード or コピーする時の実装を説明します。

仮にsizeが50%だとします。すると画像をダウンロードすると以下のような画像を取得できます。

image.png

この画像は1200 * 1200 pxのベース画像の中心に、1200 * 0.5 = 600(px)600 * 600 pxの画像を配置しています。

ちなみにベース画像が1200 * 1200 pxとなっているのは、もんたの森にアップロードしている画像はすべて1200 * 1200pxの画像になっているからです。

なんで1200 * 1200 pxのベース画像の中心に調整後の画像を配置する必要があるのか

なんでそんなことしないといけないのかについて説明します。

もんたの森では画像リサイズ機能を実装する前、画像をダウンロード or コピーする際、以下のような実装をしていました。

const response = await axios.get(src, { responseType: "blob" });
const blob = response.data;

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const image = await createImageBitmap(blob);
canvas.width = image.width;
canvas.height = image.height;
  • 1行目のsrcには画像のURLが含まれます。そのURLを元に、画像データを取得するという処理を行っています。また、responseTypeは扱いやすいBlobを指定します

  • 4行目はcanvas要素を作成しています

  • 5行目では、canvas要素に対して2Dコンテキストを渡すことで描画操作が可能になります

  • 6行目ではBlob形式の画像を効率的に扱えるようにするために、画像データをビットマップ形式に変換しています

  • 7, 8行目で取得した画像のwidthとheightをcanvas要素のwidth, heightに設定しています

このようにすることで、canvasに画像データが適用され、ダウンロードしたりコピーしたりといった操作がしやすくなるのです。

しかし、この単純な実装のままでは1200 * 1200 pxの中心にリサイズした後の画像を配置するという実装はできません。

仮に、size=50%として以下のように実装すると、600 * 600pxのデフォルトの画像が表示されるだけになってしまいます。

const image = await createImageBitmap(blob);
canvas.width = image.width * size;
canvas.height = image.height * size;

この画像が600*600 pxで作られるだけ

image.png

こういった理由より、もんたの森では1200 * 1200 pxのベース画像を作成し、その中心に調整後の画像を配置し、画像をダウンロード or コピーするようにしています。

resizeImageAndCenter関数について

1200 * 1200 pxのベース画像の中心にリサイズ後の画像を配置している理由は分かったかと思うので、実際にそれをどのように行なっているのか説明します。

リサイズ処理は以下の関数で行っています。

const resizeImageAndCenter = async (
  src: string,
  sizePercentage: number
): Promise<string | null> => {
  const response = await axios.get(src, { responseType: "blob" });
  const blob = response.data;

  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  const image = await createImageBitmap(blob);

  const sizeMultiplier = sizePercentage / 100;
  const resizedWidth = image.width * sizeMultiplier;
  const resizedHeight = image.height * sizeMultiplier;

  // キャンバスのサイズを1200px * 1200pxに固定
  const canvasSize = 1200;
  canvas.width = canvasSize;
  canvas.height = canvasSize;

  // 中央にリサイズした画像を配置するためのオフセットを計算
  const offsetX = (canvasSize - resizedWidth) / 2;
  const offsetY = (canvasSize - resizedHeight) / 2;

  ctx?.clearRect(0, 0, canvas.width, canvas.height);
  ctx?.drawImage(image, offsetX, offsetY, resizedWidth, resizedHeight);

  return new Promise<string | null>((resolve) => {
    canvas.toBlob((newBlob) => {
      if (newBlob) {
        resolve(URL.createObjectURL(newBlob)); // Blob URLを返す
      } else {
        resolve(null);
      }
    }, blob.type);
  });
};

新しく追加で実装されたのはここですね。

    // サイズ調整後の画像のwidthとheightを計算
    const sizeMultiplier = sizePercentage / 100;
    const resizedWidth = image.width * sizeMultiplier;
    const resizedHeight = image.height * sizeMultiplier;
    
    // キャンバスのサイズを1200px * 1200pxに固定
    const canvasSize = 1200;
    canvas.width = canvasSize;
    canvas.height = canvasSize;
    
    // 中央にリサイズした画像を配置するためのオフセットを計算
    const offsetX = (canvasSize - resizedWidth) / 2;
    const offsetY = (canvasSize - resizedHeight) / 2;

    // canvasを一旦クリアし、サイズ調整後の画像を中心に配置したcanvasを新たに生成
    ctx?.clearRect(0, 0, canvas.width, canvas.height);
    ctx?.drawImage(image, offsetX, offsetY, resizedWidth, resizedHeight);

やってる流れとしては以下になります。

  1. 倍率を計算し、リサイズ後の画像のサイズを決定
  2. キャンバスを1200 * 1200pxでサイズ設定
  3. リサイズ後の画像を中央に配置するためのオフセットの計算
  4. 再描画

最後はcanvasに描画されているimageをBlob形式にして新しい画像のURLを返せばこの関数の処理は完了です。

return new Promise<string | null>((resolve) => {
canvas.toBlob((newBlob) => {
  if (newBlob) {
    resolve(URL.createObjectURL(newBlob)); // Blob URLを返す
  } else {
    resolve(null);
  }
}, blob.type);
});

画像ダウンロード処理

画像ダウンロード処理について説明していきます。

const downloadImage = async (src: string, sizePercentage: number) => {
  try {
    const resizedBlobUrl = await resizeImageAndCenter(src, sizePercentage);
    if (resizedBlobUrl) {
      const fileName = src.substring(src.lastIndexOf("/") + 1);
      const a = document.createElement("a");
      a.href = resizedBlobUrl;
      a.download = fileName;
      a.click();
      URL.revokeObjectURL(resizedBlobUrl);
    }
  } catch (error) {
    console.error("Image download failed", error);
  }
};

初めに、こちらの処理でresizeImageAndCenter()で生成した画像のBlobURLを取得します。

const resizedBlobUrl = await resizeImageAndCenter(src, sizePercentage);

その後、BlobURLが存在する場合、aタグを作成し、そのaタグにhrefとdownloadを設定します。これにより、ユーザーが画像をダウンロードした時のファイル名が指定されます。

a.click()を実行することで、画像のダウンロードが実行されます。

最後に、一時的に生成したBlobURLを開放し、メモリリークを防ぎ、画像ダウンロード処理は完了です。

const a = document.createElement("a");
a.href = resizedBlobUrl;
a.download = fileName;
a.click();
URL.revokeObjectURL(resizedBlobUrl);

個人的に、aタグをこんなふうに使えること知らなかったので、初めて知った時は衝撃を受けました。

画像コピー処理

画像コピー処理について説明していきます。

const copyImageToClipboard = async (
  src: string,
  sizePercentage: number,
  setIsCopied: React.Dispatch<React.SetStateAction<boolean>>
) => {
  try {
    const resizedBlobUrl = await resizeImageAndCenter(src, sizePercentage);
    if (resizedBlobUrl) {
      const response = await fetch(resizedBlobUrl);
      const blob = await response.blob();
      const clipboardItem = new ClipboardItem({
        [blob.type]: blob,
      });
      await navigator.clipboard.write([clipboardItem]);

      // その他の処理
      
    }
  } catch (err) {
    console.error("Failed to copy on clipboard", err);
  }
};

リサイズ後の画像URLを取得する処理は先ほど説明したのでスキップします。

リサイズ後の画像URLを取得できたあとは、以下の処理でそのURLを元に画像データを取得します。
responseをblobオブジェクトに変換し、扱いやすいようにします。

const response = await fetch(resizedBlobUrl);
const blob = await response.blob();

クリップボードにコピーするので、クリップボードに格納するデータを表すオブジェクトを作成します。
それは以下で作成しています。

const clipboardItem = new ClipboardItem({
  [blob.type]: blob,
});

引数にはBlobのMIMEタイプをキーとして、blob形式に変換した画像データを渡します。
ちなみにMINEタイプとは、データの種類を示す文字列のことです。

最後に、クリップボードにデータを書き込む非同期関数を実行し、画像のコピー処理は完了になります。

await navigator.clipboard.write([clipboardItem]);

学び

今回はもんたの森という個人開発Webアプリケーションに画像のサイズを調整できる機能を実装し、以下のことを学びました!

  • canvasの使い方
  • aタグを使って画像ダウンロードを実装する方法
  • 正規表現の使い方

正直canvasの使い方とかは今回の実装をする前は曖昧だった部分が多いのですが、今回の実装で「どのように使うのか」「何を行っているのか」といった曖昧な理解だった部分がかなり明確になった気がします。

まとめ

いかがでしたでしょうか。

今回は個人で開発した「もんたの森」というWebアプリに新しい機能を実装してみたよってことを書かせていただきました!

この記事を読んで少しでも個人開発に興味を持ってくれる人が増えたらなと思います!

今後もエンジニアとして学びを続け、多くのユーザーに毎日使われるサービスを開発できればなと思います!

最後までお読みいただきありがとうございました!🐶


よかったらこちらも覗いてみてください。

【たまーに描いた絵をアップする X ( Twitter ) 】

https://x.com/monta_no_mori

【最近始めた Instagram 】

https://www.instagram.com/monta_no_mori/

【もんたのLINEスタンプ】

https://store.line.me/stickershop/author/2887587/ja

Discussion