🎉

figjamもどきWebアプリ

2025/01/15に公開

はじめに

普段、figjamやfigmaなどを使わせていただいているのですが、そもそもコードとしてはどう処理しているのか?と気になってきました。cloneアプリで勉強したところやChatGPTなどに教えてもらった事項を参照しながら、figjamもどきのWebアプリを作ってみました。(Canvasと連携すれば保存機能もつけることができます。)
参考になれば幸いです。

参照したcloneアプリ

https://www.youtube.com/watch?v=oKIThIihv60

環境設定

これから環境を設定していきます。環境はNext.JsとTailwindCSSを使用します。

npx create-next-app@latest my-image-app
Ok to proceed? (y) y
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes

すると my-image-app というディレクトリが作成されます。完了後、以下のように移動します:

cd my-image-app

また、作成されたフォルダのapp/globals.css を以下に上書きいたします。

app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

また、以下をインストールします。

npm install uuid 
npm install -D @types/uuid   

実装

ここからは、実際にページを作成していきましょう。

1.画像アップロード&重ね表示の実装

まずは画像をアップロードできて、画像を一定のエリアで重ねたりできる状態を目指します。

1-1.ファイル構成のイメージ

my-image-app/
├─ app/
│  ├─ page.tsx      // ← トップページ (クライアントコンポーネント)
│  ├─ layout.tsx    // (任意) レイアウトファイル
│  ├─ globals.css    // Tailwind の設定読み込み
│  └─ ...           
└─ package.json

app/page.tsx はアプリのトップページ。ここに "use client" + React Hooks を使って、画像をアップロードする機能を実装します。

1-2. 実装コード

app/page.tsx
app/page.tsx
"use client"; 

import { useState, ChangeEvent } from "react";

export default function Home() {
  // 画像のパス(URL)を管理するため、string 型の配列として宣言
  const [images, setImages] = useState<string[]>([]);

  // ファイルがアップロードされたときに呼ばれる関数
  const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return; // e.target.files が null かもしれないためチェック

    const files = e.target.files;
    const newImages: string[] = [];

    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      // ブラウザ上でプレビューできる一時的なURLを生成
      const imageUrl = URL.createObjectURL(file);
      newImages.push(imageUrl);
    }

    // 既にある画像リストに追加
    setImages((prev) => [...prev, ...newImages]);
  };

  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <h1 className="text-2xl font-bold mb-4 text-center">
        画像を重ねて表示するデモ
      </h1>

      {/* ファイル選択 */}
      <div className="flex justify-center mb-6">
        <input
          type="file"
          accept="image/*"
          multiple
          onChange={handleImageUpload}
          className="text-sm file:mr-4 file:py-2 file:px-4 file:border-0
                     file:text-sm file:font-semibold
                     file:bg-blue-50 file:text-blue-700
                     hover:file:bg-blue-100
                    "
        />
      </div>

      {/* 画像表示領域 */}
      <div className="relative w-full max-w-screen-md h-96 bg-white mx-auto border border-gray-300 rounded shadow overflow-hidden">
        {images.map((src, index) => (
          <img
            key={index}
            src={src}
            alt={`overlay-${index}`}
            className="absolute top-0 left-0 w-32 h-32 object-cover border-2 border-white shadow"
            style={{
              // 位置を少しずつずらしてみる (単なるサンプル)
              transform: `translate(${index * 40}px, ${index * 30}px)`,
              zIndex: index + 1,
            }}
          />
        ))}
      </div>
    </main>
  );
}

ポイント解説

  1. "use client"

    • ファイルの先頭行に追加するだけで、このページはクライアントコンポーネントとして動きます。
    • これで useState, useEffect などのフックが使えるように。
  2. useState<string[]>([])

    • TypeScript を使う場合、配列の型が不明だと never[] になりがち。
    • string[] と明示しておくと、型エラーを避けられます。
  3. アップロードされたファイルの扱い

    • ChangeEvent<HTMLInputElement> を引数に取ることで e.target.files を正しく扱えます。
    • URL.createObjectURL(file) でプレビュー用のURLを即時生成。これはブラウザを閉じると無効化される、一時的なURLです。
  4. 画像を重ねる仕組み

    • 親要素に relative、子の imgabsolute で配置し z-index を指定するだけで、画像を重ねられます。
    • 位置調整を自由に行いたい場合は、ドラッグ&ドロップや CSS のレイアウトを工夫するとよいでしょう。

これで、画像をアップロードして重ねることができました。


2.画像をドラッグでさせる

今回はそれをさらに発展させ、ドラッグ操作で画像を好きな場所に移動できる機能を実装してみます。
しかも、ドラッグ中はカーソルの位置にピタッと追従するようにすることで、より自然な操作感を実現します。

2-1.ファイル構成のイメージ

my-image-app/
├─ app/
│  ├─ page.tsx      // ← トップページ (クライアントコンポーネント)
│  ├─ layout.tsx    // (任意) レイアウトファイル
│  ├─ globals.css    // Tailwind の設定読み込み
│  └─ ...           
└─ package.json

2-2. 実装コード

app/page.tsx
app/page.tsx
"use client";

import { useState, ChangeEvent, MouseEvent } from "react";

// 画像情報を管理する型: 画像URLと座標
type ImageData = {
  src: string;
  x: number;
  y: number;
};

export default function Home() {
  // 画像データ (配列)
  const [images, setImages] = useState<ImageData[]>([]);

  // 現在ドラッグ中の画像インデックス (null = ドラッグしていない)
  const [draggingIndex, setDraggingIndex] = useState<number | null>(null);

  // ドラッグ開始時の「カーソル座標 - 画像座標」のオフセット情報
  const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);

  /**
   * 画像アップロード時の処理
   */
  const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return;

    const newImages: ImageData[] = [];

    for (let i = 0; i < e.target.files.length; i++) {
      const file = e.target.files[i];
      const imageUrl = URL.createObjectURL(file);
      // 画像の初期位置を (0,0) にして追加
      newImages.push({ src: imageUrl, x: 0, y: 0 });
    }

    // 既存の画像リストに追加
    setImages((prev) => [...prev, ...newImages]);
  };

  /**
   * 画像上でマウスを押した時: ドラッグ開始
   */
  const handleMouseDown = (e: MouseEvent<HTMLImageElement>, index: number) => {
    e.preventDefault(); 
    // ブラウザのデフォルト動作 (画像のネイティブドラッグ等) をキャンセル

    // 今ドラッグしている画像のインデックスを保持
    setDraggingIndex(index);

    // ドラッグ開始時のカーソル座標
    const mouseX = e.clientX;
    const mouseY = e.clientY;
    // 画像の現在座標
    const imgX = images[index].x;
    const imgY = images[index].y;

    // カーソルと画像の位置差を記憶 (カーソルが画像のどの部分を掴んだか)
    setOffset({
      x: mouseX - imgX,
      y: mouseY - imgY,
    });
  };

  /**
   * マウスを動かしている最中: ドラッグ中なら座標を更新
   */
  const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
    if (draggingIndex === null) return; // ドラッグしていない
    if (!offset) return;               // オフセット情報がない

    // 現在のマウスカーソル位置
    const mouseX = e.clientX;
    const mouseY = e.clientY;

    // 新しい画像座標 = 現在のマウス位置 - オフセット
    const newX = mouseX - offset.x;
    const newY = mouseY - offset.y;

    setImages((prev) => {
      // 対象の画像だけ座標を更新
      const updated = [...prev];
      updated[draggingIndex] = {
        ...updated[draggingIndex],
        x: newX,
        y: newY,
      };
      return updated;
    });
  };

  /**
   * マウスを離したとき: ドラッグ終了
   */
  const handleMouseUp = () => {
    setDraggingIndex(null);
    setOffset(null);
  };

  return (
    <main
      className="min-h-screen bg-gray-100 p-8"
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      <h1 className="text-2xl font-bold mb-4 text-center">
        画像ドラッグ移動デモ
      </h1>

      {/* ファイル選択 */}
      <div className="flex justify-center mb-6">
        <input
          type="file"
          accept="image/*"
          multiple
          onChange={handleImageUpload}
          className="text-sm file:mr-4 file:py-2 file:px-4 file:border-0
                     file:text-sm file:font-semibold
                     file:bg-blue-50 file:text-blue-700
                     hover:file:bg-blue-100
                    "
        />
      </div>

      {/* 画像エリア */}
      <div
        className="relative w-full max-w-screen-md h-96 bg-white mx-auto
                   border border-gray-300 rounded shadow overflow-hidden"
      >
        {images.map((img, index) => (
          <img
            key={index}
            src={img.src}
            alt={`overlay-${index}`}
            onMouseDown={(e) => handleMouseDown(e, index)}
            className="absolute w-32 h-32 object-cover border-2 border-white shadow
                       cursor-move"
            style={{
              left: img.x,
              top: img.y,
              zIndex: index + 1,
            }}
          />
        ))}
      </div>
    </main>
  );
}

2-3. ドラッグ中にカーソルと同時に動く仕組みのポイント

  1. オフセット (offset) を計算

    • 画像を掴んだ瞬間に「クリック(タップ)した箇所と、画像の左上座標との距離」を計算します。
    • これを offset ( { x, y } ) として保持しておく。
  2. ドラッグ中の画像位置 = (マウス座標) - (offset)

    • onMouseMove でマウスが動くたびに、画像座標を mouseX - offset.x, mouseY - offset.y に設定。
    • こうするとカーソル位置に合わせて画像がぴったり追従します。
  3. ドラッグ終了

    • onMouseUpdraggingIndexoffsetnull に戻してリセット。
    • もうドラッグ中ではないので、マウスが動いても座標は更新されません。

最初は、ドラッグと同期できなかったのですが、ChatGPTに聞いて同期できるようになりました。


3.画像を選択した際に前面に画像を出す方法

3-1.ファイル構成

my-image-app/
├─ app/
│  ├─ page.tsx
│  └─ components/
│     └─ DraggableImages.tsx   // ← 新しく作るコンポーネント
├─ package.json
└─ ...

3-2. 実装コード

app/components/DraggableImages.tsx
app/components/DraggableImages.tsx
"use client";

import { useState, ChangeEvent, MouseEvent } from "react";

/**
 * 画像情報の型
 * - src: 画像URL
 * - x, y: 画像の左上座標
 * - z: 画像のz-index
 */
type ImageData = {
  src: string;
  x: number;
  y: number;
  z: number;
};

/**
 * DraggableImages コンポーネント
 * - 複数画像をアップロードし、ドラッグで移動&z-indexを動的に変更
 */
export default function DraggableImages() {
  // 画像の配列を管理
  const [images, setImages] = useState<ImageData[]>([]);

  // 現在ドラッグ中の画像インデックス (null = ドラッグしていない)
  const [draggingIndex, setDraggingIndex] = useState<number | null>(null);

  // ドラッグ開始時のカーソルと画像座標のオフセット
  const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);

  // z-index の最大値を記録するためのカウンター
  // 画像をドラッグするたびにインクリメントして、最前面に持ってくる
  const [zCounter, setZCounter] = useState<number>(1);

  /**
   * 画像ファイルがアップロードされたときの処理
   */
  const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return;

    const newImages: ImageData[] = [];
    for (let i = 0; i < e.target.files.length; i++) {
      const file = e.target.files[i];
      const imageUrl = URL.createObjectURL(file);

      // 追加する画像の初期値: (0, 0) に配置し、z=1 とする
      newImages.push({
        src: imageUrl,
        x: 0,
        y: 0,
        z: 1,
      });
    }

    // 既存の画像リストに追加
    setImages((prev) => [...prev, ...newImages]);
  };

  /**
   * 画像上でマウスを押したとき: ドラッグ開始
   */
  const handleMouseDown = (e: MouseEvent<HTMLImageElement>, index: number) => {
    e.preventDefault(); // ブラウザのデフォルト動作 (画像のネイティブドラッグ) をキャンセル

    // ドラッグ開始時に zCounter を更新し、最前面にする
    setZCounter((prev) => {
      const nextZ = prev + 1;

      // クリックされた画像のzを nextZ に更新
      setImages((prevImages) => {
        const updated = [...prevImages];
        updated[index] = {
          ...updated[index],
          z: nextZ,
        };
        return updated;
      });

      return nextZ;
    });

    // ドラッグしている画像のインデックスを保持
    setDraggingIndex(index);

    // マウスカーソルと画像座標の差分を offset として保持
    const mouseX = e.clientX;
    const mouseY = e.clientY;
    const imgX = images[index].x;
    const imgY = images[index].y;

    setOffset({
      x: mouseX - imgX,
      y: mouseY - imgY,
    });
  };

  /**
   * ドラッグ中 (マウス移動) に座標を更新
   */
  const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
    if (draggingIndex === null) return;
    if (!offset) return;

    // 現在のマウス位置
    const mouseX = e.clientX;
    const mouseY = e.clientY;

    // 新しい画像座標 = (マウス座標 - offset)
    const newX = mouseX - offset.x;
    const newY = mouseY - offset.y;

    setImages((prev) => {
      const updated = [...prev];
      updated[draggingIndex] = {
        ...updated[draggingIndex],
        x: newX,
        y: newY,
      };
      return updated;
    });
  };

  /**
   * マウスを離したらドラッグ終了
   */
  const handleMouseUp = () => {
    setDraggingIndex(null);
    setOffset(null);
  };

  return (
    <div
      className="relative w-full max-w-screen-md h-96 bg-white mx-auto
                 border border-gray-300 rounded shadow overflow-hidden"
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      {/* 画像ファイル選択(お好みで場所を変えてもOK) */}
      <div className="absolute top-2 left-2 z-50 bg-white p-2 rounded shadow">
        <input
          type="file"
          accept="image/*"
          multiple
          onChange={handleImageUpload}
          className="text-sm file:mr-4 file:py-1 file:px-2 file:border-0
                     file:text-sm file:font-semibold
                     file:bg-blue-50 file:text-blue-700
                     hover:file:bg-blue-100
                    "
        />
      </div>

      {/* 画像の表示 */}
      {images.map((img, index) => (
        <img
          key={index}
          src={img.src}
          alt={`overlay-${index}`}
          onMouseDown={(e) => handleMouseDown(e, index)}
          className="absolute w-32 h-32 object-cover border-2 border-white shadow
                     cursor-move"
          style={{
            left: img.x,
            top: img.y,
            zIndex: img.z,
          }}
        />
      ))}
    </div>
  );
}
app/page.tsx
app/page.tsx
import DraggableImages from "./components/DraggableImages";

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <h1 className="text-2xl font-bold mb-6 text-center">
        画像をドラッグ&前面表示できるデモ
      </h1>
      {/* ここに先ほどのコンポーネントを配置 */}
      <DraggableImages />
    </main>
  );
}

これまでは src, x, y のみでしたが、今回新たに z を追加し、各画像が「どのレイヤーにいるか」を管理します。

3-3.zCounter を用意してドラッグ開始時にインクリメント

// z-index の最大値を記録
const [zCounter, setZCounter] = useState(1);

// ...

const handleMouseDown = (e: MouseEvent<HTMLImageElement>, index: number) => {
  e.preventDefault();

  // ドラッグ開始時に zCounter を更新
  setZCounter((prev) => {
    const nextZ = prev + 1;

    setImages((prevImages) => {
      const updated = [...prevImages];
      updated[index] = {
        ...updated[index],
        z: nextZ, // ここでzを最新に
      };
      return updated;
    });

    return nextZ;
  });

  // あとはドラッグ処理の続き
  // ...
};
  • ドラッグが始まるたびに zCounter+1
  • 対象の画像の z をその都度 zCounter に設定することで、今クリックした画像が最前面へ。

こうすることで、ドラッグが始まった瞬間にその画像だけ z-index を最大値に更新し、最前面に持ってきます。

4.画像が運べる領域を限定する。

以下では、領域外への移動制限を加えたコンポーネントを新たに作成し、
画像がドラッグされても コンテナ領域外に飛び出さない ようにするコードを紹介します。

4-1.ファイル構成

my-image-app/
├─ app/
│  ├─ page.tsx
│  └─ components/
│     └─ DraggableImages.tsx   
│     └─ BoundedDraggableImages.tsx // ← 新しく作るコンポーネント
├─ package.json
└─ ...

4-2. 実装コード

app/components/BoundedDraggableImages.tsx
app/components/BoundedDraggableImages.tsx
"use client";

import { useState, ChangeEvent, MouseEvent, useRef } from "react";

/**
 * 画像情報の型
 * - src: 画像URL
 * - x, y: 画像の左上座標
 * - z: z-index
 */
type ImageData = {
  src: string;
  x: number;
  y: number;
  z: number;
};

export default function BoundedDraggableImages() {
  // 画像を管理
  const [images, setImages] = useState<ImageData[]>([]);
  // ドラッグ中の画像インデックス (null = ドラッグしていない)
  const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
  // マウスカーソルと画像のオフセット (ドラッグ開始時の差)
  const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);
  // z-index の最大値を管理 (最前面に持ってくるため)
  const [zCounter, setZCounter] = useState<number>(1);

  // 画像を表示するコンテナ要素への参照
  const containerRef = useRef<HTMLDivElement>(null);

  // 画像サイズを固定想定 (Tailwind の w-32, h-32 = 128px)
  const IMAGE_SIZE = 128;

  /**
   * 画像アップロード
   */
  const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return;

    const newImages: ImageData[] = [];
    for (let i = 0; i < e.target.files.length; i++) {
      const file = e.target.files[i];
      const imageUrl = URL.createObjectURL(file);
      newImages.push({
        src: imageUrl,
        x: 0,
        y: 0,
        z: 1,
      });
    }
    setImages((prev) => [...prev, ...newImages]);
  };

  /**
   * 画像上でマウスを押した時: ドラッグ開始
   */
  const handleMouseDown = (e: MouseEvent<HTMLImageElement>, index: number) => {
    e.preventDefault();

    // ドラッグ対象の指定
    setDraggingIndex(index);

    // ドラッグ開始時にzIndexを最大化する
    setZCounter((prev) => {
      const nextZ = prev + 1;

      // クリックされた画像だけz更新
      setImages((prevImages) => {
        const updated = [...prevImages];
        updated[index] = {
          ...updated[index],
          z: nextZ,
        };
        return updated;
      });

      return nextZ;
    });

    // マウスカーソルと画像座標の差を記録
    const mouseX = e.clientX;
    const mouseY = e.clientY;
    const imgX = images[index].x;
    const imgY = images[index].y;

    setOffset({
      x: mouseX - imgX,
      y: mouseY - imgY,
    });
  };

  /**
   * マウスを動かしている最中
   */
  const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
    if (draggingIndex === null || !offset) return;

    // 新しい座標 (暫定)
    const mouseX = e.clientX;
    const mouseY = e.clientY;
    let newX = mouseX - offset.x;
    let newY = mouseY - offset.y;

    // コンテナ領域を取得
    const containerRect = containerRef.current?.getBoundingClientRect();
    if (containerRect) {
      const { width, height } = containerRect;
      // x座標の範囲制限
      newX = Math.max(0, Math.min(newX, width - IMAGE_SIZE));
      // y座標の範囲制限
      newY = Math.max(0, Math.min(newY, height - IMAGE_SIZE));
    }

    // 画像座標更新
    setImages((prev) => {
      const updated = [...prev];
      updated[draggingIndex] = {
        ...updated[draggingIndex],
        x: newX,
        y: newY,
      };
      return updated;
    });
  };

  /**
   * マウスを離した時: ドラッグ終了
   */
  const handleMouseUp = () => {
    setDraggingIndex(null);
    setOffset(null);
  };

  return (
    <div className="relative w-full max-w-screen-md mx-auto">
      {/* ファイル選択 */}
      <div className="mb-2">
        <input
          type="file"
          accept="image/*"
          multiple
          onChange={handleImageUpload}
          className="text-sm file:mr-4 file:py-1 file:px-2 file:border-0
                     file:text-sm file:font-semibold
                     file:bg-blue-50 file:text-blue-700
                     hover:file:bg-blue-100
                    "
        />
      </div>

      {/* 画像を表示するコンテナ要素 */}
      <div
        ref={containerRef}
        className="relative w-full h-96 bg-white border border-gray-300
                   rounded shadow overflow-hidden"
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
      >
        {images.map((img, index) => (
          <img
            key={index}
            src={img.src}
            alt={`overlay-${index}`}
            onMouseDown={(e) => handleMouseDown(e, index)}
            className="absolute w-32 h-32 object-cover border-2 border-white shadow
                       cursor-move"
            style={{
              left: img.x,
              top: img.y,
              zIndex: img.z,
            }}
          />
        ))}
      </div>
    </div>
  );
}
app/page.tsx
app/page.tsx
import BoundedDraggableImages from "./components/BoundedDraggableImages";

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <h1 className="text-2xl font-bold mb-4 text-center">
        領域外への移動を制限したドラッグデモ
      </h1>
      <BoundedDraggableImages />
    </main>
  );
}

既存のドラッグロジックをベースにしつつ、領域外に出ないように座標をクリップ(制限) しています。

主な修正ポイント

  1. コンテナ要素のサイズを取得
    • React の useRefgetBoundingClientRect() を使って、実際の表示領域 (幅・高さ) を取得します。
  2. handleMouseMovecontainerRef.current?.getBoundingClientRect() を取得し、newX, newY を以下の範囲に収める:
    • 0 <= newX <= containerWidth - 画像幅
    • 0 <= newY <= containerHeight - 画像高さ
  3. IMAGE_SIZE = 128固定 で定義しており、Tailwind の w-32 h-32 と揃えています。
    • w-32 は通常 Tailwind で 8rem (128px) 相当です。
    • もし画像サイズが動的に変化するなら、onLoaduseLayoutEffect などで実際の要素サイズを計測する必要があります。
  4. マウス移動時に座標をクリップ(制限)
    • 新しい x 座標を 0 <= x <= (コンテナ幅 - 画像幅) に収める。
    • 新しい y 座標を 0 <= y <= (コンテナ高さ - 画像高さ) に収める。

これだけで、画像が枠外にはみ出さないドラッグを実現できます。


5.拡大縮小対応の画像を自由に“重ねる”制御

前回までの記事で、ドラッグ&拡大縮小できる ResizableDraggableImages.tsx を紹介しました。
すでにこのコンポーネントでは「マウスドラッグで座標を移動」「拡大縮小 (scale) の指定」が可能です。

5-1.ファイル構成

my-image-app/
├─ app/
│  ├─ page.tsx
│  └─ components/
│     └─ DraggableImages.tsx 
│     └─ BoundedDraggableImages.ts  
│     └─ ResizableDraggableImages.tsx // ← 新しく作るコンポーネント
├─ package.json
└─ ...

5-2. 実装コード

app/components/ResizableDraggableImages.tsx
app/components/ResizableDraggableImages.tsx
"use client";

import { useState, ChangeEvent, MouseEvent, useRef } from "react";

/**
 * 画像情報の型
 * - src: アップロードした画像URL
 * - x, y: 画像の左上座標 (ドラッグ移動)
 * - z: z-index (ドラッグ開始時に最前面に)
 * - scale: 拡大率 (1.0 = 等倍)
 */
type ImageData = {
  src: string;
  x: number;
  y: number;
  z: number;
  scale: number;
};

export default function ResizableDraggableImages() {
  // 画像リスト管理
  const [images, setImages] = useState<ImageData[]>([]);
  // ドラッグ中の画像インデックス(null = ドラッグしていない)
  const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
  // マウスカーソルと画像座標の差
  const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);
  // ドラッグ時に最大の z-index を割り当てるカウンタ
  const [zCounter, setZCounter] = useState<number>(1);

  // 親コンテナを参照: 領域外へ出ないようサイズを確認する
  const containerRef = useRef<HTMLDivElement>(null);

  // 画像の「基準サイズ」として、Tailwind の w-32,h-32 (128px) を仮定
  // scale を掛けて最終的な大きさを求めます
  const BASE_SIZE = 128;

  /**
   * ファイルアップロード時に画像を追加
   */
  const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return;

    const newImages: ImageData[] = [];
    for (let i = 0; i < e.target.files.length; i++) {
      const file = e.target.files[i];
      const url = URL.createObjectURL(file);

      newImages.push({
        src: url,
        x: 0,       // 初期座標は (0,0) に配置
        y: 0,
        z: 1,       // 初期 z-index
        scale: 1.0, // 等倍
      });
    }

    setImages((prev) => [...prev, ...newImages]);
  };

  /**
   * ドラッグ開始 (onMouseDown)
   * - 画像を最前面に (z-index 更新)
   * - マウスカーソルと画像座標の差分 (offset) を求める
   */
  const handleMouseDown = (e: MouseEvent<HTMLDivElement>, index: number) => {
    e.preventDefault();

    setDraggingIndex(index);

    // ドラッグ開始時に zCounter をインクリメントして、最前面へ
    setZCounter((prev) => {
      const nextZ = prev + 1;

      setImages((imgList) => {
        const updated = [...imgList];
        updated[index] = {
          ...updated[index],
          z: nextZ,
        };
        return updated;
      });

      return nextZ;
    });

    const mouseX = e.clientX;
    const mouseY = e.clientY;
    const { x, y } = images[index];

    setOffset({
      x: mouseX - x,
      y: mouseY - y,
    });
  };

  /**
   * ドラッグ中 (onMouseMove)
   * - 新しい (x,y) 座標を計算し、コンテナ外に出ないようクリップ
   */
  const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
    if (draggingIndex === null || !offset) return;

    const mouseX = e.clientX;
    const mouseY = e.clientY;

    // 暫定の座標
    let newX = mouseX - offset.x;
    let newY = mouseY - offset.y;

    // コンテナのサイズを取得
    const rect = containerRef.current?.getBoundingClientRect();
    if (rect) {
      const containerWidth = rect.width;
      const containerHeight = rect.height;

      // 今ドラッグしている画像の scale
      const scale = images[draggingIndex].scale;
      // 実際の画像サイズ
      const scaledSize = BASE_SIZE * scale;

      // 0 <= newX <= containerWidth - scaledSize
      const maxX = containerWidth - scaledSize;
      newX = Math.max(0, Math.min(newX, maxX));

      // 0 <= newY <= containerHeight - scaledSize
      const maxY = containerHeight - scaledSize;
      newY = Math.max(0, Math.min(newY, maxY));
    }

    // state を更新
    setImages((prev) => {
      const updated = [...prev];
      updated[draggingIndex] = {
        ...updated[draggingIndex],
        x: newX,
        y: newY,
      };
      return updated;
    });
  };

  /**
   * ドラッグ終了 (onMouseUp)
   */
  const handleMouseUp = () => {
    setDraggingIndex(null);
    setOffset(null);
  };

  /**
   * 拡大縮小のスライダーで scale を変更
   */
  const handleScaleChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
    const newScale = parseFloat(e.target.value);

    // スライダーの値を反映
    setImages((prev) => {
      const updated = [...prev];
      updated[index] = {
        ...updated[index],
        scale: newScale,
      };
      return updated;
    });
  };

  return (
    <div className="w-full max-w-2xl mx-auto">
      {/* ファイル選択ボタン */}
      <div className="mb-4">
        <input
          type="file"
          accept="image/*"
          multiple
          onChange={handleImageUpload}
          className="text-sm file:mr-4 file:py-1 file:px-2 file:border-0
                     file:text-sm file:font-semibold
                     file:bg-blue-50 file:text-blue-700
                     hover:file:bg-blue-100
                    "
        />
      </div>

      {/* ドラッグ&表示用のコンテナ */}
      <div
        ref={containerRef}
        className="relative w-full h-96 bg-gray-100 border border-gray-300
                   rounded shadow overflow-hidden"
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
      >
        {images.map((img, index) => {
          // 拡大率に基づく実際の画像サイズ
          const scaledSize = BASE_SIZE * img.scale;

          return (
            <div
              key={index} // ★ key を付与して、React 警告を回避
              onMouseDown={(e) => handleMouseDown(e, index)}
              className="absolute cursor-move flex flex-col"
              style={{
                left: img.x,
                top: img.y,
                width: scaledSize,
                height: scaledSize,
                zIndex: img.z,
              }}
            >
              {/* 実際の画像 */}
              <img
                src={img.src}
                alt={`img-${index}`}
                className="w-full h-full object-cover"
              />

              {/* 拡大縮小スライダー */}
              <input
                type="range"
                min={0.5}
                max={2.0}
                step={0.1}
                value={img.scale}
                onChange={(e) => handleScaleChange(e, index)}
                // スライダー操作を親要素の onMouseDown と分離するため
                // stopPropagation しておくとより操作しやすくなる場合も
                onMouseDown={(e) => e.stopPropagation()}
                className="mt-auto bg-white bg-opacity-80
                           border-t border-gray-300
                           hover:bg-opacity-100"
              />
            </div>
          );
        })}
      </div>
    </div>
  );
}
app/page.tsx
app/page.tsx
import ResizableDraggableImages from "./components/ResizableDraggableImages";

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <h1 className="text-2xl font-bold mb-6 text-center">
        画像のドラッグ&拡大縮小&レイヤー重ねデモ
      </h1>

      {/* 先ほどのコンポーネントを呼び出すだけでOK */}
      <ResizableDraggableImages />
    </main>
  );
}

5-3.ポイント

  1. key={index}<div> に追加

    • Each child in a list should have a unique "key" prop. という警告を回避。
    • 大規模アプリでは、uuid など真にユニークな ID を使うのがベターな場合もあります。
  2. ドラッグ&拡大縮小の両立

    • 親要素 (div) の onMouseDown でドラッグ開始を受け取るため、スライダー (input[type="range"]) を操作するときは stopPropagation してドラッグ処理をキャンセルできます。
    • onMouseDown={(e) => e.stopPropagation()} をスライダーに付けることで、つまみをドラッグしても画像が動かずスライダー値だけ変わるようになります。
  3. 拡大率

    • min={0.5} max={2.0} step={0.1} として、0.5~2.0倍まで調整可能に。
    • BASE_SIZE (128px) * scale が最終的な画像幅高さとなり、コンテナからはみ出さないように onMouseMove でクリップしています。

【応用】ここまでの内容をコンポーネント分割まで徹底解説

ディレクトリ構成例

my-image-overlay-app/
├─ app/
│  ├─ page.tsx
│  └─ components/
│     ├─ ImageUploader.tsx
│     ├─ DraggableImage.tsx
│     ├─ GalleryContainer.tsx
│     └─ ResizeButtons.tsx
├─ package.json
└─ ...
  1. ImageUploader.tsx: 画像をアップロードし、親に渡すだけの小コンポーネント
  2. DraggableImage.tsx: 1枚の画像をドラッグ移動 (x,y), scale, z-index を管理
  3. GalleryContainer.tsx: 複数画像をリストレンダリングし、まとめて座標更新
  4. page.tsx: レイアウトだけ担当、ImageUploaderGalleryContainer を配置

コード実装

app/components/ImageUploader.tsx
app/components/ImageUploader.tsx
"use client";

import { ChangeEvent, FC } from "react";

type ImageUploaderProps = {
  onUpload: (urls: string[]) => void; 
};

const ImageUploader: FC<ImageUploaderProps> = ({ onUpload }) => {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return;
    const files = e.target.files;
    const newUrls: string[] = [];

    for (let i = 0; i < files.length; i++) {
      const url = URL.createObjectURL(files[i]);
      newUrls.push(url);
    }

    onUpload(newUrls);
  };

  return (
    <div className="mb-4">
      <label className="inline-block text-sm font-medium text-gray-700 mr-2">
        画像をアップロード:
      </label>
      <input
        type="file"
        multiple
        accept="image/*"
        onChange={handleChange}
        className="text-sm file:mr-4 file:py-1 file:px-2 file:border-0
                   file:text-sm file:font-semibold
                   file:bg-blue-50 file:text-blue-700
                   hover:file:bg-blue-100"
      />
    </div>
  );
};

export default ImageUploader;
app/components/DraggableImage.tsx
app/components/DraggableImage.tsx
"use client";

import { MouseEvent, FC, useRef } from "react";
import ResizeButtons from "./ResizeButtons";

export type ImageItem = {
  id: string;    // ユニークID
  src: string;   // 画像URL
  x: number;     // X座標
  y: number;     // Y座標
  z: number;     // z-index
  scale: number; // 拡大率
};

type DraggableImageProps = {
  image: ImageItem;
  baseSize: number;
  onDragStart: (id: string, mouseX: number, mouseY: number) => void;
  onDragMove: (mouseX: number, mouseY: number) => void;
  onDragEnd: () => void;
  onZoomIn: (id: string) => void;
  onZoomOut: (id: string) => void;
  onBringToFront: (id: string) => void;
};

const DraggableImage: FC<DraggableImageProps> = ({
  image,
  baseSize,
  onDragStart,
  onDragMove,
  onDragEnd,
  onZoomIn,
  onZoomOut,
  onBringToFront,
}) => {
  const scaledSize = baseSize * image.scale;
  const containerRef = useRef<HTMLDivElement>(null);

  // マウスを押した瞬間 (ドラッグ開始)
  const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
    e.preventDefault();
    onDragStart(image.id, e.clientX, e.clientY);
  };

  return (
    <div
      ref={containerRef}
      onMouseDown={handleMouseDown}
      className="absolute cursor-move transition-all duration-100"
      style={{
        left: image.x,
        top: image.y,
        width: scaledSize,
        height: scaledSize,
        zIndex: image.z,
      }}
    >
      <img
        src={image.src}
        alt={image.id}
        className="w-full h-full object-cover"
      />

      {/* 拡大縮小 & 前面ボタン */}
      <ResizeButtons
        scale={image.scale}
        onZoomIn={() => onZoomIn(image.id)}
        onZoomOut={() => onZoomOut(image.id)}
      />
    </div>
  );
};

export default DraggableImage;
app/components/GalleryContainer.tsx
app/components/GalleryContainer.tsx
"use client";

import { FC, useState, MouseEvent, useEffect, useRef } from "react";
import DraggableImage, { ImageItem } from "./DraggableImage";
import { v4 as uuidv4 } from "uuid";

type GalleryContainerProps = {
  baseSize?: number;
  initialUrls: string[]; // ここに親から受け取る画像URL
};

const GalleryContainer: FC<GalleryContainerProps> = ({
  baseSize = 128,
  initialUrls,
}) => {
  const [images, setImages] = useState<ImageItem[]>([]);
  const [draggingId, setDraggingId] = useState<string | null>(null);
  const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);
  const [zCounter, setZCounter] = useState(1);

  const containerRef = useRef<HTMLDivElement>(null);

  // 親から受け取った initialUrls が更新されたら、ImageItem に変換して追加
  useEffect(() => {
    // initialUrls が変わるたびに、まだ登録されていないURLをImageItemに変換
    // すでにimagesにあるsrcは重複追加しないなどのロジックも入れられます
    const newItems = initialUrls
      .filter((url) => !images.some((img) => img.src === url))
      .map((url) => ({
        id: uuidv4(),
        src: url,
        x: 0,
        y: 0,
        z: 1,
        scale: 1,
      }));

    if (newItems.length > 0) {
      setImages((prev) => [...prev, ...newItems]);
    }
  }, [initialUrls, images]);

  // 以下、ドラッグ処理やズーム処理は従来どおり
  const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
    if (!draggingId || !offset) return;
    let newX = e.clientX - offset.x;
    let newY = e.clientY - offset.y;

    const img = images.find((item) => item.id === draggingId);
    const rect = containerRef.current?.getBoundingClientRect();
    if (img && rect) {
      const scale = img.scale;
      const scaledSize = baseSize * scale;
      newX = Math.max(0, Math.min(newX, rect.width - scaledSize));
      newY = Math.max(0, Math.min(newY, rect.height - scaledSize));
    }
    setImages((prev) =>
      prev.map((x) => (x.id === draggingId ? { ...x, x: newX, y: newY } : x))
    );
  };

  const handleMouseUp = () => {
    setDraggingId(null);
    setOffset(null);
  };

  const onDragStart = (id: string, mx: number, my: number) => {
    const img = images.find((x) => x.id === id);
    if (!img) return;
    setDraggingId(id);
    setOffset({ x: mx - img.x, y: my - img.y });
    // z-indexを最大化
    setZCounter((prev) => {
      const nextZ = prev + 1;
      setImages((prevImages) =>
        prevImages.map((x) => (x.id === id ? { ...x, z: nextZ } : x))
      );
      return nextZ;
    });
  };

  const onZoomIn = (id: string) => {
    setImages((prev) =>
      prev.map((x) =>
        x.id === id && x.scale < 2
          ? { ...x, scale: parseFloat((x.scale + 0.1).toFixed(2)) }
          : x
      )
    );
  };

  const onZoomOut = (id: string) => {
    setImages((prev) =>
      prev.map((x) =>
        x.id === id && x.scale > 0.5
          ? { ...x, scale: parseFloat((x.scale - 0.1).toFixed(2)) }
          : x
      )
    );
  };

  const onBringToFront = (id: string) => {
    setZCounter((prev) => {
      const nextZ = prev + 1;
      setImages((prevImages) =>
        prevImages.map((x) => (x.id === id ? { ...x, z: nextZ } : x))
      );
      return nextZ;
    });
  };

  return (
    <div
      ref={containerRef}
      className="relative w-full h-96 bg-gray-200 border border-gray-300
                 rounded-md shadow overflow-hidden"
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      {images.map((img) => (
        <DraggableImage
          key={img.id}
          image={img}
          baseSize={baseSize}
          onDragStart={onDragStart}
          onDragMove={() => {}}
          onDragEnd={handleMouseUp}
          onZoomIn={onZoomIn}
          onZoomOut={onZoomOut}
          onBringToFront={onBringToFront}
        />
      ))}
    </div>
  );
};

export default GalleryContainer;
app/components/ResizeButtons.tsx
app/components/ResizeButtons.tsx
"use client";

import { FC } from "react";

type ResizeButtonsProps = {
  scale: number;
  onZoomIn: () => void;
  onZoomOut: () => void;
  onBringToFront: () => void;
};

const ResizeButtons: FC<ResizeButtonsProps> = ({
  scale,
  onZoomIn,
  onZoomOut,
}) => {
  return (
    <div
      className="absolute right-0 bottom-0 flex flex-col space-y-1 
                 p-1 bg-white bg-opacity-80 border-l border-t border-gray-300
                 text-xs"
      // 親要素のドラッグを発生させないように
      onMouseDown={(e) => e.stopPropagation()}
    >
      <button
        onClick={onZoomIn}
        className="px-2 py-1 bg-blue-100 hover:bg-blue-200 border
                   border-blue-300 rounded"
      >
        +
      </button>
      <button
        onClick={onZoomOut}
        className="px-2 py-1 bg-blue-100 hover:bg-blue-200 border
                   border-blue-300 rounded"
      >
        -
      </button>
      <div className="text-center text-gray-600">
        {scale.toFixed(1)}x
      </div>
    </div>
  );
};

export default ResizeButtons;
app/page.tsx
app/page.tsx
"use client";

import { useState } from "react";
import ImageUploader from "./components/ImageUploader";
import GalleryContainer from "./components/GalleryContainer";

export default function Home() {
  // ここで「アップロードされた画像URLの一覧」を管理する
  const [imageUrls, setImageUrls] = useState<string[]>([]);

  const handleUpload = (urls: string[]) => {
    // 既存URLに追加していく
    setImageUrls((prev) => [...prev, ...urls]);
  };

  return (
    <main className="min-h-screen bg-gradient-to-b from-blue-50 to-blue-100 p-8">
      <h1 className="text-3xl font-bold mb-6 text-center text-blue-700 drop-shadow">
        画像ドラッグ&拡大縮小デモ
      </h1>

      <div className="max-w-4xl mx-auto bg-white rounded-md shadow p-6">
        {/* アップローダ */}
        <ImageUploader onUpload={handleUpload} />

        {/* ギャラリーコンテナに「imageUrls」を渡す */}
        <GalleryContainer baseSize={128} initialUrls={imageUrls} />
      </div>
    </main>
  );
}

これで、画像を多少いじれるWebアプリが機能ごとに完成しました。

  • ImageUploader でファイルをアップロードし、URL配列を親コンポーネント (ここでは page.tsx) が受け取る
  • そのURLを GalleryContainer に渡して addImages()する仕組みにすると、一連の動作がつながります。
  • 例示では galleryRef のように参照を持って直接呼ぶ方法と、state リフティング (page 内で images state を持ってコンテナに渡す) 方法が考えられます。

最後に

この勉強を仕事に活かしていきたいです。

Discussion