ツクログ

tsukulognet

tsukulognet

道産子。Reactでなまら面白いものを作りたい。

【React】Twitterのようなアイコン画像を生成するUIを作る

eye catch

作るもの

下記のような、ドラッグ操作で画像を移動させたり、レンジスライダーの操作で画像を拡大・縮小させたりしてアイコン画像を生成するUIを作ります。 Screenshot 50

開発環境の構築

Reactアプリの開発環境の構築についてはReactの開発環境を構築するをご覧ください。

ディレクトリ構成

プロジェクトのディレクトリ構成を下記のようにします。「my-app」はプロジェクト名です。

my-app
 └── src
     ├── components
     │   ├── IconImageGenerator.js
     │   └── RangeSlider.js
     ├── hooks
     │   ├── useDragAndDrop.js
     │   ├── useIconImageGenerator.js
     │   ├── useMutationObserver.js
     |   ├── useResizeObserver.js
     │   └── useRangeSlider.js
     ├── App.css
     ├── App.js
     ├── constants.js
     ├── index.css
     ├── index.js
     └── utils.js

今回は、App.css、index.css、index.jsの内容はプロジェクト作成時のままです。

styled-componentsのインストール

コンポーネントにスタイルを持たせるために、ターミナルで以下のコマンドを実行してstyled-componentsをインストールします。

yarn add styled-components

定数を定義する

src配下のconstants.jsに下記の定数を定義します。

constants.js

// 画像のURL
const BG = "image.jpg";

// canvas要素の最大値
const MAX_CANVAS_SIZE = 600;

// レンジスライダーのハンドルが一度に進む距離
const RANGE_SLIDER_STEP = 20;

ユーティリティ関数を定義する

src配下のutils.jsに下記のユーティリティ関数を定義します。

utils.js

export const clamp = (value, min, max) => {
    if (value < min)
        return min
    else if (value > max)
        return max
    return value
}

export const loadImage = (url) => {
    return new Promise((resolve) => {
        const img = new Image();
        img.src = url;
        img.crossOrigin = "Anonymous";
        img.addEventListener("load", e => {
            resolve(e.target);
        });
    });
};

clamp関数は、valueの変化をminからmaxまでの範囲に制限します。

loadImage関数は、Promiseで読み込んだ画像を返す関数です。

カスタムフックを作成する

コンポーネントのロジック部分を他のコンポーネントでも使い回せるようにするため、コンポーネントのロジック部分を抽出した関数であるカスタムフックを作成します。

useDragAndDrop

hooks/useDragAndDrop.jsに以下のコードを書き、DOM要素のドラッグ&ドロップを可能にするuseDragAndDropフックを作成します。

useDragAndDropフックについては【React hooks】要素のドラッグ&ドロップを可能にするカスタムフックを作成するをご覧ください。

useMutationObserver

hooks/useMutationObserver.jsに以下のコードを書き、DOM要素の変化を検知するuseMutationObserverフックを作成します。

useMutationObserverフックについては【React hooks】要素の変化を検知して何かをするカスタムフックを作成するをご覧ください。

useResizeObserver

hooks/useResizeObserver.jsに以下のコードを書き、DOM要素のリサイズを検知するuseResizeObserverフックを作成します。

useResizeObserverフックについては【React hooks】要素のリサイズを検知して何かをするカスタムフックを作成するをご覧ください。

useRangeSlider

hooks/useRangeSlider.jsに以下のコードを書き、RangeSliderコンポーネントのロジックを抽出した関数であるuseRangeSliderフックを作成します。

useRangeSliderフックについてはReactでレンジスライダーを作るをご覧ください。

useIconImageGenerator

hooks/useIconImageGenerator.jsに以下のコードを書き、IconImageGeneratorコンポーネントのロジックを抽出した関数であるuseIconImageGeneratorフックを作成します。

useIconImageGenerator.js

import { 
    useState,
    useCallback,
    useRef,
    useEffect,
    useReducer 
} from 'react'

import useDragAndDrop from '../hooks/useDragAndDrop'
import useRangeSlider from '../hooks/useRangeSlider'
import useMutationObserver from '../hooks/useMutationObserver'
import useResizeObserver from '../hooks/useResizeObserver'

import { MAX_CANVAS_SIZE, RANGE_SLIDER_STEP } from '../constants' 
import { loadImage, clamp } from '../utils'

const useIconImageGenerator = (imageUrl) => {
  const [
        { 
            currentDragElement,
            elementPosition,
            elementOffset
        },
        { 
            pointerStartPosition,
            pointerMovePosition
        },
        handleMouseDown,
        setElementOffset,
        resetElementOffset,
        setElementPosition
    ] = useDragAndDrop();
    const [
        rangeSlider,
        setRangeSliderHandleOffsetX,
        isDetectionResizeRangeSliderBar
    ] = useRangeSlider();

    const { handle, bar } = rangeSlider
    const { 
        element: rangeSliderHandleElement,
        offsetX:rangeSliderHandleOffsetX,
        position: rangeSliderHandlePosition,
        width: rangeSliderHandleWidth
    } = handle
    const {
        element: rangeSliderBarElement,
        position: rangeSliderBarPosition,
        width: rangeSliderBarWidth,
        scale: rangeSliderBarScale
    } = bar

    const canvasElement = useRef(null);
    const draggableImageElement = useRef(null);
    const imageElement = useRef(null);
    const dragAreaElement = useRef(null);
    const frameElement = useRef(null);

    const initialImageSize = useRef({ height: 0, width: 0 })

    const draggableImagePosition = useRef({ top: 0, left: 0, right: 0, bottom: 0 })
    const [draggableImageSize, setDraggableImageSize] = useState({ height: 0, width: 0 })

    const framePosition = useRef({ left: 0, top: 0, right: 0, bottom: 0 })
    const [frameSize, setFrameSize] = useState({ height: 0, width: 0 })
    const frameScale = useRef(1);

    const [generatedImage, setGeneratedImage] = useState(null);

    const isFirstResize = useRef(false)
    const isDetectionResizeFrame = useRef(false)

  const fitImage = async (h, w) => {
        const image = await loadImage(imageUrl).then((r) => r);

        const width = image.width;
        const height = image.height;

        const ratio = height / width;

        if (ratio >= 1) {
            setDraggableImageSize({
                height: height * (w / width),
                width: w
            })

            initialImageSize.current.height = height * (w / width)
            initialImageSize.current.width = w
        } else {
            setDraggableImageSize({
                height: h,
                width: width * (h / height)
            })

            initialImageSize.current.height = h
            initialImageSize.current.width = width * (h / height)
        } 
    }

    const pushBackDraggableImage = () => {
        if (draggableImagePosition.current.left >= framePosition.current.left) {
            setElementOffset((p) => ({
                x: p.x - Math.abs(draggableImagePosition.current.left -  framePosition.current.left),
                y: p.y
            }));
        }

        if (draggableImagePosition.current.right <=  framePosition.current.right) {
            setElementOffset((p) => ({
                x: p.x + Math.abs(draggableImagePosition.current.right -  framePosition.current.right),
                y: p.y
            }));
        }

        if (draggableImagePosition.current.top >=  framePosition.current.top) {
            setElementOffset((p) => ({
                x: p.x,
                y: p.y - Math.abs(draggableImagePosition.current.top -  framePosition.current.top)
            }));
        }

        if (draggableImagePosition.current.bottom <=  framePosition.current.bottom) {
            setElementOffset((p) => ({
                x: p.x,
                y: p.y + Math.abs(draggableImagePosition.current.bottom -  framePosition.current.bottom)
            }));
        }
    }

   const updateDraggableImageScale = () => {
        const rangeSliderStatus = clamp(
            rangeSliderHandleOffsetX / rangeSliderBarWidth,
            0,
            rangeSliderBarWidth
        );

        const imageScale = 1 + rangeSliderStatus;

        setDraggableImageSize({
            height: initialImageSize.current.height * imageScale,
            width: initialImageSize.current.width * imageScale
        })
    };

  const handleMouseWheel = useCallback((e) => {
        if (!rangeSliderBarWidth) return;

        const deltaY = e.deltaY;

        if (e.deltaY > 0) {
            setRangeSliderHandleOffsetX((p) => 
                clamp(p - RANGE_SLIDER_STEP * rangeSliderBarScale.current, 0, rangeSliderBarWidth)
            );
        } else {
            setRangeSliderHandleOffsetX((p) => 
                clamp(p + RANGE_SLIDER_STEP * rangeSliderBarScale.current, 0, rangeSliderBarWidth)
            );
        }
    });

  const getGeneratedImage = async () => {
        if (!canvasElement.current) return;
        const windowHeight = window.innerHeight
        const windowWidth = window.innerWidth

        const windowRatio = windowHeight / windowWidth

        const canvasSize = (windowRatio >= 1) ? clamp(windowWidth, 0, MAX_CANVAS_SIZE) : clamp(windowHeight, 0, MAX_CANVAS_SIZE)
        canvasElement.current.width = canvasSize;
        canvasElement.current.height = canvasSize;

        const ctx = canvasElement.current.getContext("2d");

        const canvasRect = canvasElement.current.getBoundingClientRect();

        const image = await loadImage(imageElement.current.src).then((r) => r);

        const frameRect = frameElement.current.getBoundingClientRect()

        const imageRect = draggableImageElement.current.getBoundingClientRect()

        const ratio =
            initialImageSize.current.height >= initialImageSize.current.width
                ? canvasRect.width / frameRect.width
                : canvasRect.height / frameRect.height;

        ctx.drawImage(
            image,
            (imageRect.left - frameRect.left) * ratio,
            (imageRect.top - frameRect.top) * ratio,
            imageRect.width * ratio,
            imageRect.height * ratio
        );

        if (canvasElement.current) {
            const generatedImage = canvasElement.current.toDataURL("image/jpeg");
            setGeneratedImage(generatedImage);
        }
    };

  const resetIconImageGenerator = () => {
        setElementOffset({
            x: 0,
            y: 0
        });

        setRangeSliderHandleOffsetX(0);

        setDraggableImageSize({
            height: 0,
            width: 0
        })
    };

  useMutationObserver(
        [draggableImageElement],
        (mutations) => {
            const frameRect = frameElement.current.getBoundingClientRect()

            const rect = mutations[0].target.getBoundingClientRect();
            draggableImagePosition.current = {
                left: rect.left,
                right: rect.right,
                top: rect.top,
                bottom: rect.bottom
            }

            framePosition.current = {
                left: frameRect.left,
                right: frameRect.right,
                top: frameRect.top,
                bottom: frameRect.bottom
            }

            if(isDetectionResizeRangeSliderBar.current) {
                isDetectionResizeRangeSliderBar.current = false
                isDetectionResizeFrame.current = false
                return
            }

            if(isDetectionResizeFrame.current) {
                isDetectionResizeFrame.current = false
                isDetectionResizeRangeSliderBar.current = false
                return
            }           

            pushBackDraggableImage()

            getGeneratedImage()
        },
        {
            attributes: true,
            subtree: false,
            childList: false,
            attributeFilter: ["class"]
        }
    )

  useResizeObserver(
        [frameElement],
        (entries) => {
            isDetectionResizeFrame.current = true

            const rect = entries[0].target.getBoundingClientRect();
            const resizedFrameHeight = rect.height;
            const resizedFrameWidth = rect.width;

            // ウィンドウのサイズが0pxになったあとに再び0より大きくなったときに画像のサイズが0pxになってしまうのを防ぐために、ウィンドウのサイズが0pxのときは処理を抜ける
            if(resizedFrameHeight === 0 || resizedFrameWidth === 0) {
                return
            }

            framePosition.current = {
                left: rect.left,
                right: rect.right,
                top: rect.top,
                bottom: rect.bottom
            }

            // コンポーネントのレンダリング後に一度だけ呼び出す
            if(!isFirstResize.current) {
                isFirstResize.current = true
                fitImage(resizedFrameHeight, resizedFrameWidth)
            }

            const ratio = initialImageSize.current.height / initialImageSize.current.width;

            if (ratio >= 1) {
                frameScale.current =
                    initialImageSize.current.width === 0
                        ? 1
                        : resizedFrameWidth / initialImageSize.current.width
            } else {
                frameScale.current =
                    initialImageSize.current.height === 0
                        ? 1
                        : resizedFrameHeight / initialImageSize.current.height
            }

            initialImageSize.current.height *= frameScale.current
            initialImageSize.current.width *= frameScale.current

            setFrameSize({
                height: resizedFrameHeight,
                width: resizedFrameWidth
            })

            setElementOffset((p) => ({
                x: p.x * frameScale.current,
                y: p.y * frameScale.current
            }));

            getGeneratedImage()
        }
    )

  useEffect(() => {
        updateDraggableImageScale();
    }, [rangeSliderHandleOffsetX, frameSize.height, frameSize.width]);

  return [
        {
            dragAreaElement,
            draggableImage: {
                element: draggableImageElement,
                size: draggableImageSize,
                offset: elementOffset
            },
            imageElement,
            frameElement,
            canvasElement,
            rangeSlider,
            handleMouseDown,
            handleMouseWheel
        },
        generatedImage,
        resetIconImageGenerator
    ];
}

export default useIconImageGenerator

useIconImageGeneratorフックを呼び出すと以下のものが返ります。

返り値 説明
iconImageGenerator IconImageGeneratorコンポーネントにセットするプロパティで構成されたオブジェクト
generatedImage 編集した画像のURLを取得する関数
resetIconImageGenerator 画像の編集をリセットする関数

useIconImageGeneratorフックを呼び出すと次の処理が行われます。

  • レンジスライダーやマウスホイールの操作によって画像が拡大・縮小されるようにする
  • 画像の外側が枠内に入らないようにする
  • 画像の情報を更新する
  • 枠の情報を更新する
  • 編集中の画像URLを取得する

レンジスライダーやマウスホイールの操作によって画像が拡大・縮小されるようにする

レンジスライダーのハンドルをドラッグ、又はバーがクリックされたときに、updateDraggableImageScale関数を実行して画像の倍率が変わるようにします。

useEffect(() => {
        updateDraggableImageScale();
}, [rangeSliderHandleOffsetX, frameSize.height, frameSize.width]);

また、枠がリサイズされると画像の初期サイズも変更するため、useEffectフックの第二引数の配列内に枠のサイズframeSizeを入れます。これにより、枠がリサイズされた後もupdateDraggableImageScale関数が実行されるようになります。

updateDraggableImageScale関数では、レンジスライダーのハンドルの進捗率progressRateから画像の拡大率imageScaleを取得し、それを画像の初期サイズに掛けて画像の倍率を変更します。

const updateDraggableImageScale = () => {
        const progressRate = clamp(
            rangeSliderHandleOffsetX / rangeSliderBarWidth,
            0,
            rangeSliderBarWidth
        );

        const imageScale = 1 + progressRate;

        setDraggableImageSize({
            height: initialImageSize.current.height * imageScale,
            width: initialImageSize.current.width * imageScale
        })
    };

また、handleMouseWheel関数をDragAreaコンポーネントのonWheel属性に設定することで、ドラッグ領域上でマウスホイールを回転させた場合も、画像の倍率が変わるようにします。

具体的には、

  1. handleMouseWheel関数が実行されると、マウスホイールを回転させた分だけハンドルが移動しする
  2. ハンドルが移動すると、useEffectフックによってupdateDraggableImageScale関数が呼ばれるため、画像の倍率が変化する

ということです。

handleMouseWheel関数の、マウスホイールの回転によってハンドルが移動する仕組みは、mousewheelイベントが発生したときのイベントオブジェクトのdeltaYプロパティの値が0より大きければマウスホイールを手前に回転させたということなので、回転させた数だけオフセットからステップ幅RANGE_SLIDER_STEPを差し引くことでハンドルが戻るということです。

if (e.deltaY > 0) {
            setRangeSliderHandleOffsetX((p) => 
                clamp(p - 20 * rangeSliderBarScale.current, 0, rangeSliderBarWidth)
            );
}

反対に0より小さければ向こうに回転させたということなので回転させた数だけオフセットにステップ幅RANGE_SLIDER_STEPを加えてハンドルが進むということです。

else {
    setRangeSliderHandleOffsetX((p) => 
      clamp(
      p + RANGE_SLIDER_STEP * rangeSliderBarScale.current,
      0,
      rangeSliderBarWidth
    )
  )
}

尚、ハンドルを進める際に、ステップ幅をそのままオフセットに加えると、ハンドルがどこまでも進んだりあるいは戻ったりしてしまうため、clamp関数で移動範囲を制限します。

画像の外側が枠内に入らないようにする

ドラッグで画像が移動しているときや、レンジスライダーの操作で画像が拡大・縮小しているときに、画像の外側が枠内に入らないように次のことをします。

  • useMutationObserverフックを呼び出して画像を監視する
  • リサイズ以外での画像の変化、つまりドラッグによる画像の移動かレンジスライダーによる画像の拡大・縮小を検知したときに、pushBackDraggableImage関数を実行する

pushBackDraggableImage関数が実行されると、例えば画像の左側の座標draggableImagePosition.current.leftが枠の左側の座標framePosition.current.left以上であれば、画像の左側が枠内に入ったということなので、画像のオフセットから入った分(Math.abs(draggableImagePosition.current.left - framePosition.current.left))を差し引いて、常に画像の左側が枠内に入らないようにします。

if (draggableImagePosition.current.left >= framePosition.current.left) {
  setElementOffset((p) => ({
      x: p.x - Math.abs(draggableImagePosition.current.left -  framePosition.current.left),
        y: p.y
    }));
}

右・上・下方向の判定も同様に行います。

また、上記の判定は、常に最新の値同士で行うため、pushBackDraggableImage関数を実行する前に、MutationObserverのコールバック関数内で、画像の位置と枠の位置を最新の値に更新します。

draggableImagePosition.current = {
  left: rect.left,
    right: rect.right,
    top: rect.top,
    bottom: rect.bottom
}

framePosition.current = {
    left: frameRect.left,
    right: frameRect.right,
    top: frameRect.top,
    bottom: frameRect.bottom
}

pushBackDraggableImage()

尚、上記の項目で「リサイズ以外での画像の変化」としている理由は、MutationObserverはウィンドウの伸縮(リサイズ)による画像サイズの変化も検知するため、それを検知したときにpushBackDraggableImage関数が実行されると、画像の位置がずれてしまいます。

なので、下記のようにして、レンジスライダーのバー又は枠がリサイズされたときは、処理を終了させてpushBackDraggableImage関数が実行されないようにします。

if(isDetectionResizeRangeSliderBar.current) {
            isDetectionResizeRangeSliderBar.current = false
         isDetectionResizeFrame.current = false
            return
        }

            if(isDetectionResizeFrame.current) {
                isDetectionResizeFrame.current = false
                isDetectionResizeRangeSliderBar.current = false
                return
            }

上記のコードで、先にisDetectionResizeRangeSliderBar.currentの判定を行っている理由は、useIconImageGeneratorフック内ではuseRangeSliderフックが先に実行されているため、レンジスライダーのリサイズが枠のリサイズより先に行われます。そのため、isDetectionResizeRangeSliderBar.currentの判定を先に行う必要があるのです。

また、上記のそれぞれのif文のブロック内で、isDetectionResizeRangeSliderBar.currentisDetectionResizeFrame.currentの両方をfalseに切り替えていますが、

if(isDetectionResizeRangeSliderBar.current) {
            isDetectionResizeRangeSliderBar.current = false
            return
        }

if(isDetectionResizeFrame.current) {
                isDetectionResizeFrame.current = false
                return
            }

のように、それぞれ片方のみをfalseに切り替えただけでは、枠がリサイズされた後にレンジスライダーのバーをクリックして画像を縮小させたときに、pushBackDraggableImage関数が実行されずに画像の外側が枠の内側に入ってしまうため、いけません。

なぜpushBackDraggableImage関数が実行されずに画像の外側が枠の内側に入ってしまうのかといいますと、まず、枠のリサイズ前にレンジスライダーのリサイズが先に検知されるため、isDetectionResizeRangeSliderBar.currentがtrueに切り替わります。

次に、枠のリサイズが検知されてisDetectionResizeFrame.currentがtrueに切り替わります。この時点でisDetectionResizeRangeSliderBar.currentisDetectionResizeFrame.currentはそれぞれtrue, trueです。

そして、MutationObserverによって、枠の変化(リサイズ)が検知されて、1つ目の判定

if(isDetectionResizeRangeSliderBar.current) {
            isDetectionResizeRangeSliderBar.current = false
            return
        }

が行われると、isDetectionResizeRangeSliderBar.currentがfalseに切り替わり、returnによってそこで処理が終了します。この時点でisDetectionResizeRangeSliderBar.currentisDetectionResizeFrame.currentはそれぞれfalse, trueです。

つまり、いくら枠がリサイズされても1つ目の判定でreturnされるため、2つ目の判定

if(isDetectionResizeFrame.current) {
                isDetectionResizeFrame.current = false
                return
            }

は行われず、isDetectionResizeFrame.currentはtrueのまま変わることはないのです。

なので、その状態でレンジスライダーのバーをクリックして、ハンドルをその位置に移動させたとき(画像を縮小させたとき)に画像の外側が枠の外側を越えても、通常であればpushBackDraggableImage関数が実行されて、入った分だけ押し戻されますが、2つ目のif文の判定が通ってそこで処理が終わるため、pushBackDraggableImage関数は実行されずに画像は押し戻されることなくそのまま画像の外側が枠の内側に入ってしまうということです。

画像のサイズを枠に合わせる

ページ表示後に画像が枠に収まっている状態にするため、fitImage関数を実行して画像のサイズを引数として与えられた枠のサイズに合わせます。ただし、画像の縦横比を保つために、画像の幅と高さのうち、小さい方を枠に合わせて、大きいほうを枠からはみ出すようにします。つまり、画像が縦長であれば横を枠にぴったり合わせて、縦をはみ出すようにします。また、正方形の画像も縦長と同様にします。

const width = image.width;
        const height = image.height;

        const ratio = height / width;

        if (ratio >= 1) {
            setDraggableImageSize({
                height: height * (w / width),
                width: w
            })

            initialImageSize.current.height = height * (w / width)
            initialImageSize.current.width = w
        }

横長の画像であればその反対です。

else {
            setDraggableImageSize({
                height: h,
                width: width * (h / height)
            })

            initialImageSize.current.height = h
            initialImageSize.current.width = width * (h / height)
        } 

はみ出した部分はcssのoverflow: hidden;で隠します。

尚、今回作成するUIでは、ページ表示後に画像が枠に収まっている状態であればよいため、ページ表示後に一度だけ実行します。実行する場所は、枠のDOM要素frameElementを監視するresizeObserverフックのコールバック関数内です。

ページ表示後に枠のリサイズが検知されるので、そのときに得られる枠の幅と高さを引数に与えます。

if(!isFirstResize.current) {
                isFirstResize.current = true
                fitImage(resizedFrameHeight, resizedFrameWidth)
            }

画像の情報を更新する

次のことが起こったときに、画像の情報を更新します。

タイミング 更新される値
枠がリサイズされたとき オフセット値(elementOffset)
初期サイズ(initialDraggableImageSize)
移動中の位置(draggableImagePosition)
ドラッグで画像が移動したとき 移動中の位置(draggableImagePosition)
レンジスライダーの操作によって画像の倍率が変更されたとき 移動中の位置(draggableImagePosition)

枠がリサイズされたときに更新するには、枠のDOM要素を監視しているResizeObserverのコールバック関数内で更新します。

useResizeObserver(
        [frameElement],
        (entries) => {
            // ここで更新する
    }
)

移動中の位置draggableImagePositionを更新するには、

draggableImagePosition.current = {
                left: rect.left,
                right: rect.right,
                top: rect.top,
                bottom: rect.bottom
            }

のように、現在の位置で上書きするだけですが、オフセット値と初期サイズは現在の値を取得する方法がないため、ある割合で規模を拡大または縮小します。

ある割合とは、画像の初期サイズに対する枠サイズの変化率frameScaleです。

この変化率を算出する際、画像が縦長か正方形であれば、初期サイズの幅に対する枠の幅の割合となります。横長であれば、初期サイズの高さに対する枠の高さの割合となるようにします。

なぜそのようにするのかというと、fitImage関数によって画像が枠にぴったり収まった状態を維持したまま、枠のサイズの変化に応じて、画像が拡大・縮小されるようにするためです。

const ratio = initialImageSize.current.height / initialImageSize.current.width;

            if (ratio >= 1) {
                frameScale.current =
                    initialImageSize.current.width === 0
                        ? 1
                        : resizedFrameWidth / initialImageSize.current.width
            } else {
                frameScale.current =
                    initialImageSize.current.height === 0
                        ? 1
                        : resizedFrameHeight / initialImageSize.current.height
            }

尚、上記の変化率を算出している所で、

frameScale.current =
                    initialImageSize.current.width === 0
                        ? 1
                        : resizedFrameWidth / initialImageSize.current.width

のように三項演算子で条件分岐している理由は、初期サイズが0であれば、枠のサイズを0で割ることになるため、変化率がNaNになってしまいます。これを防ぐため、初期サイズが0であれば変化率が1になるようにしています。

こうして得た変化率をオフセット値と初期サイズに掛けることで、枠のリサイズと同じ規模で拡大または縮小されるため、オフセット値と初期サイズが正しく更新されます。

initialImageSize.current.height *= frameScale.current
            initialImageSize.current.width *= frameScale.current

setElementOffset((p) => ({
                x: p.x * frameScale.current,
                y: p.y * frameScale.current
            }));

そもそもなぜ、枠がリサイズされたときに画像のオフセット値と初期サイズを更新するのかというと、枠のサイズの変化に合わせて更新するためです。

枠のサイズの変化に合わせて、同じ規模で画像のオフセット値と初期サイズを更新することで、画像が現在の枠内の状態のまま拡大・縮小されるようになるのです。

枠の情報を更新する

次のことが起こったときに、以下の枠の情報を更新します。

タイミング 更新される値
枠がリサイズされたとき 枠の位置(framePosition)
枠のサイズ(frameSize)
ドラッグで画像が移動したとき 枠の位置(framePosition)
レンジスライダーの操作によって画像の倍率が変更されたとき 枠の位置(framePosition)

画像がドラッグで移動したとき、またはレンジスライダーの操作によって倍率が変更されたときに値を更新するには、ドラッグ画像を監視するMutationObserverのコールバック関数内で更新します。

useMutationObserver(
        [draggableImageElement],
        (mutations) => {

      // ここで更新する

    },
  {
            attributes: true,
            subtree: false,
            childList: false,
            attributeFilter: ["class"]
        }
    )

なぜ、画像がドラッグで移動したとき、またはレンジスライダーの操作によって倍率が変更されたときに、変化のない枠の位置を更新する必要があるのか。

それはですね、cssで枠の位置やサイズの単位にvminを指定しているため、あるところでサイズが固定されます。

そのため、固定された後に画像をドラッグで移動、またはレンジスライダーで拡大・縮小すると、枠の位置は固定される直前のまま(固定された後はリサイズが起こらないため)であるため、その状態でpushBackDraggableImage関数が実行されると、画像の位置がずれてしまいます。

なので、画像がドラッグで移動したとき、またはレンジスライダーの操作によって倍率が変更されたときに、固定された直後の画像をドラッグしても位置がずれないように枠の位置を更新する必要があるのです。

枠で囲まれている部分の画像URLを取得する

次のことが起こった時に、getGeneratedImage関数を実行して、枠で囲まれている部分の画像URLを取得します。

  • ドラッグで画像が移動したとき
  • レンジスライダーの操作によって画像が拡大・縮小されたとき
  • 枠がリサイズされたとき

上記のタイミングで枠で囲まれている部分の画像URLを取得するには、以下の場所でgetGeneratedImage関数を実行します。

タイミング 実行する場所
ドラッグで画像が移動したとき 画像を監視しているMutationObserverのコールバック関数内
レンジスライダーの操作によって画像が拡大・縮小されたとき 画像を監視しているMutationObserverのコールバック関数内
枠がリサイズされたとき 枠を監視しているResizeObserverのコールバック関数内

getGeneratedImage関数では、次の処理を行います。

  • canvasのサイズを指定する
  • 枠に囲まれている部分の画像をcanvasに描画する
  • canvasに描画した画像のURLを取得する

canvasのサイズは、ウィンドウの幅に対するウィンドウの高さの比率windowRatioが1以上であれば現在のウィンドウは縦長または正方形ということなので、canvasのサイズをウィンドウの幅×ウィンドウの幅にします。

反対に1未満であればウィンドウは横長ということなので、canvasのサイズをウィンドウの高さ×ウィンドウの高さにします。

要は、cssのvminのように、ウィンドウの縦・横の小さいほうの長さにcanvasを合わせるのです。

ただし、これだと描画する画像のサイズが大きくなってしまい、ドラッグ等の動作が重くなってしまうため、clamp関数でcanvasの最大サイズを決めます。

const windowHeight = window.innerHeight
        const windowWidth = window.innerWidth

        const windowRatio = windowHeight / windowWidth

        const canvasSize = (windowRatio >= 1) ? clamp(windowWidth, 0, MAX_CANVAS_SIZE) : clamp(windowHeight, 0, MAX_CANVAS_SIZE)
        canvasElement.current.width = canvasSize;
        canvasElement.current.height = canvasSize;

このようにすることで、常に正方形なcanvasが表示されます。

こうしてできたcanvasに、枠で囲まれている部分の画像を描画します。

枠内に収まっている範囲の画像をそのままcanvasにぴったりと描画するには、現在の画像の枠に対する相対位置とサイズをそれぞれcanvasサイズ / 枠のサイズスケールに変更します。

const ratio =
            initialImageSize.current.height >= initialImageSize.current.width//draggableImageSize.height > draggableImageSize.width
                ? canvasRect.width / frameRect.width
                : canvasRect.height / frameRect.height;

        ctx.drawImage(
            image,
            (imageRect.left - frameRect.left) * ratio,
            (imageRect.top - frameRect.top) * ratio,
            imageRect.width * ratio,
            imageRect.height * ratio
        );

最後に、ctxのtoDataURLメソッドで描画した画像のURLを取得し、setGeneratedImage関数で枠で囲まれた範囲の画像URLgeneratedImageを更新します。

if (canvasElement.current) {
            const generatedImage = canvasElement.current.toDataURL("image/jpeg");
            setGeneratedImage(generatedImage);
        }

コンポーネントを作成する

以下のコンポーネントを作成します。

RangeSlider

components/RangeSlider.jsに以下のコードを書き、レンジスライダーを描画するRangeSliderコンポーネントを作成します。

RangeSliderコンポーネントについてはReactでレンジスライダーを作るをご覧ください。

IconImageGenerator

components/IconImageGenerator.jsに以下のコードを書き、アイコン画像ジェネレーターを描画するIconImageGeneratorコンポーネントを作成します。

IconImageGenerator.js

import React from 'react'
import styled from 'styled-components'
import RangeSlider from './RangeSlider'

const StyledIconImageGenerator = styled.div`
    margin: 3vmin auto 0;
    width: 50vmin;
`;

const DragArea = styled.div`
    border-radius: 2vmin;
    box-sizing: border-box;
    cursor: move;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: center;
    margin-bottom: 2vmin;
    overflow: hidden;
    padding: 2vmin;
    position: relative;
    height: 50vmin;
    width: 100%;
`;

const DraggableImage = styled.div`
    height: ${({ size }) => size.height}px;
    position: absolute;
    transform: translate3d(
        ${({ offsetX }) => offsetX}px,
        ${({ offsetY }) => offsetY}px,
        0
    );
    width: ${({ size }) => size.width}px;
    z-index: 0;
`;

const Frame = styled.div`
    box-sizing: border-box;
    box-shadow: rgba(230, 236, 240, 0.7) 0px 0px 0px 4vmin;
    border: 0.6vmin solid #ff4a59;
    height: 100%;
    position: relative;
    pointer-events: none;// これがないとドラッグしたときに画像が正しく移動しない。なぜなのかは現在検証中
    width: 100%;
    z-index: 1;
`;

const Img = styled.img`
    bottom: 0;
    height: 100%;
    left: 0;
    position: absolute;
    right: 0;
    top: 0;
    width: 100%;
    z-index: -1;
`;

const Canvas = styled.canvas`
    background-color: red;
    position: absolute;
    z-index: -1000;
`;

const IconImageGenerator = ({
    className,
    imageUrl,
    iconImageGenerator
}) => (
    <StyledIconImageGenerator>
        <DragArea
            ref={iconImageGenerator.dragAreaElement}
            onMouseDown={iconImageGenerator.handleMouseDown}
            onWheel={iconImageGenerator.handleMouseWheel}
        >
            <DraggableImage
                ref={iconImageGenerator.draggableImage.element}
                size={iconImageGenerator.draggableImage.size}
                offsetX={iconImageGenerator.draggableImage.offset.x}
                offsetY={iconImageGenerator.draggableImage.offset.y}
            >
                <Img
                    src={imageUrl}
                    ref={iconImageGenerator.imageElement}
                    draggable="false"
                />
            </DraggableImage>
            <Frame ref={iconImageGenerator.frameElement} />
            <Canvas ref={iconImageGenerator.canvasElement} draggable="false" />
        </DragArea>
        <RangeSlider rangeSlider={iconImageGenerator.rangeSlider} />
    </StyledIconImageGenerator>
)

export default IconImageGenerator

IconImageGeneratorコンポーネントは、propsとしてクラス名className・画像のURLimageUrliconImageGeneratorオブジェクトを受け取ります。

そして、iconImageGeneratorオブジェクトの各プロパティを下層コンポーネントの各属性に設定します。

各コンポーネントについては次の通りです。

コンポーネント名 説明
StyledIconImageGenerator IconImageGeneratorコンポーネントのラッパーコンポーネント
DragArea ドラッグ領域を描画するコンポーネント
DraggableImage ドラッグ可能な画像を描画するコンポーネント
Img 元画像を描画するコンポーネント
Frame 画像を囲む枠を描画するコンポーネント
Canvas 編集中の画像が描画されるcanvasを描画するコンポーネント
RangeSlider レンジスライダーを描画するコンポーネント

DraggableImageコンポーネントはsizeとoffsetX, offsetYのpropsを受け取ります。

sizeはheightとwidthに割り当てられます。これにより、DragAreaコンポーネントのonMouseDown属性にhandleMouseDown関数を設定すると、ドラッグによる画像の移動が可能になります。

offsetX, offsetYはtransform: translate3dのxとyに割り当てられます。これにより、レンジスライダーを操作したときに、画像の倍率が変わるようになります。

また、DragAreaコンポーネントのonWheel属性にhandleMouseWheel関数を設定すると、ドラッグ領域上でマウスホイールを回転させたときも、画像の倍率が変わるようになります。

使用例

IconImageGeneratorコンポーネントをアプリ内で使うと以下のようになります。

App.js

import IconImageGenerator from './components/IconImageGenerator';
import useIconImageGenerator from './hooks/useIconImageGenerator';
import { BG } from './constants';

const StyledApp = styled.div`
    background-color: #8e82bf;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100vh;
    width: 100%;
`;

const App = () => {
    const [
        iconImageGenerator,
        generatedImage,
        resetIconImageGenerator
    ] = useIconImageGenerator(BG);

    return (
        <StyledApp>
            <IconImageGenerator
                imageUrl={BG}
                iconImageGenerator={iconImageGenerator}
            />
        </StyledApp>
    );
};

export default App

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

ReactDOM.render(<App />, document.getElementById("root"));

DEMO

下記で実際に動作を確認できます。