[Vue3]ECサイトによくある画像のズームプレビューを作ってみた

# この記事のゴール

![画像ズーム.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/567748/83bc319e-a0b5-546d-a9ee-1915049c9dd6.gif)

# 概要

ECサイトを見てると、 **商品画像にカーソル合わせてフォーカスしている部分を拡大する**機能ありますよね。

あれをVue.jsで書いたらどうなるの？

ということで書いてみました。

# 環境

・Vue3
・tailwindcss

Vue３では、Vue2で言うところのCompositionAPIの書き方をしています。

tailwindcssはCSSのコード量減るし、変なクラス名を量産させなくて良いので気に入ってます。
（独特な書き方ですが、VSCodeだとプラグイン入れるとpx数とか表示されるので便利です♪）

# 準備

既にVue/Nuxtでローカル環境がある場合は次のステップまで飛ばしちゃってください！

vue-cliを使用してvueの環境構築します。

```
yarn global add @vue/cli
```
or

```
npm install -g @vue/cli
```

インストールが終わったら

```
vue create プロジェクト名
```
で、質問に答えて簡単に環境構築できます。
私はTypescriptで書きたいので

最初の質問では

```
> Manually select features 
```
を選択しています！これを選択するとTypeScriptが選べます。


# コード

前置きが長くなりましたが、実際の画像をズームするコンポーネントのコードです。


## template内のコード

```vuejs:ImageZoom.vue
<template>
  <div class="relative">
    <div class="lg:ml-10">
      <div
        class="relative product-image"
        @mouseenter="showZoomArea = true"
        @mouseleave="showZoomArea = false"
        @mousemove="zoomPreviewArea"
      >
　　       <!-- 元の画像の表示 -->
        <div class="product-image-content">
          <img
            :src="imageList[showingImageIndex].url"
            class="center px-2 lg:px-0 product-image-current"
          />
        </div>
        <!-- 元の画像の上でカーソルが動くときに表示される半透明の白い部分 -->
        <div
          v-if="showZoomArea"
          class="zoom-area hidden lg:block"
          :style="zoomArea"
        />
        <!-- 拡大された画像 -->
        <div
          v-if="showZoomArea"
          class="previewer-area hidden lg:block"
          :style="previewerArea"
        >
          <div class="previewer">
            <img
              :src="imageList[showingImageIndex].url"
              :style="previewerImg"
            />
          </div>
          <div class="previewer-inner" />
        </div>
      </div>
    </div>
    <div class="list">
      <ul>
        <li v-for="(image, index) in imageList" :key="index">
          <img :src="image.url" :alt="image.alt" @click="onClick(index)" />
        </li>
      </ul>
    </div>
  </div>
</template>
```

```
@mouseenter="showZoomArea = true"
@mouseleave="showZoomArea = false"
```
ここで画像の上にカーソルが来たら拡大された画像が表示されるように、カーソルが外れたら拡大画像は消えるように制御しています。

```
@mousemove="zoomPreviewArea"
```
ここでカーソルが画像の上で動いたときにどの部分を拡大してどう表示させるか、を制御します。
この **zoomPreviewArea** がメインとなる実装部分ですね！


## script内のコード

```vuejs:ImageZoom.vue
<script lang="ts">
import { defineComponent, PropType, ref } from 'vue';

export default defineComponent({
  props: {
    imageList: {
      type: Array as PropType<{ key: number | string }[]>,
      default: (): [] => [],
    },
  },
  setup() {
    const showingImageIndex = ref(0);
    const showZoomArea = ref(false);

    const onClick = (index: number): void => {
      showingImageIndex.value = index;
    };
    // 拡大された画像に適用するスタイル
    const previewerImg = ref<{ [key: string]: string }>();
    // 拡大された画像を囲んでいる部分に適用するスタイル
    const previewerArea = ref<{ [key: string]: string }>();
    // 半透明部分に適用するスタイル
    const zoomArea = ref<{ [key: string]: string }>();
    // 元画像の上に出る半透明のエリアを設定する変数
    const leftPosition = ref(0);
    const topPosition = ref(0);
    // 元画像の上に出る半透明のサイズ
    const sizeWidth = 250;
    const sizeHeight = 350;

    const zoomPreviewArea = (event: MouseEvent): void => {
      const productImageWrap = document.querySelector('.product-image');
      const productImageElement = document.querySelector(
        '.product-image-current'
      );
      const productImageElementWidth =
        productImageElement?.getBoundingClientRect().width ?? 0;
      const productImageElementHeight =
        productImageElement?.getBoundingClientRect().height ?? 0;
      const limitX = productImageElementWidth - sizeWidth;
      const limitY = productImageElementHeight - sizeHeight;

      previewerArea.value = {
        width: `${productImageElementWidth}px`,
        height: `${productImageElementHeight}px`,
        right: `${productImageElementWidth - 76}px`,
      };

      const rectObj = productImageWrap?.getBoundingClientRect();
      const scrollX = window.pageXOffset;
      const scrollY = window.pageYOffset;
      const offsetX = rectObj
        ? event.pageX - (rectObj.left + scrollX)
        : event.pageX;
      const offsetY = rectObj
        ? event.pageY - (rectObj.top + scrollY)
        : event.pageY;

      // ポインタの位置基準を中央にしている
      leftPosition.value = offsetX - sizeWidth / 2;
      topPosition.value = offsetY - sizeHeight / 2;
      if (leftPosition.value < 0) {
        leftPosition.value = 0;
      } else if (leftPosition.value > limitX) {
        leftPosition.value = limitX;
      }
      if (topPosition.value < 0) {
        topPosition.value = 0;
      } else if (topPosition.value > limitY) {
        topPosition.value = limitY;
      }
      const cutLeft = Math.floor((leftPosition.value / limitX) * 100);
      const cutTop = Math.floor((topPosition.value / limitY) * 100);

      zoomArea.value = {
        top: `${topPosition.value}px`,
        left: `${leftPosition.value}px`,
      };
      previewerImg.value = {
        'object-fit': 'none',
        'object-position': `${cutLeft}% ${cutTop}%`,
        width: `${productImageElementWidth / 2}px`,
        transform: `scale(2.0)`,
        'transform-origin': 'top left',
      };
    };
    return {
      showingImageIndex,
      onClick,
      zoomArea,
      showZoomArea,
      previewerImg,
      zoomPreviewArea,
      previewerArea,
    };
  },
});
</script>
```

補足をしますと

```
const productImageElementWidth =
        productImageElement?.getBoundingClientRect().width ?? 0;
const productImageElementHeight =
        productImageElement?.getBoundingClientRect().height ?? 0;
```
ここでは元画像のサイズを取得しています


```
const limitX = productImageElementWidth - sizeWidth;
const limitY = productImageElementHeight - sizeHeight;
```
これは半透明の部分が画像の端っこにいく位置を求めています。
この位置を求めないと画像以外の部分でも半透明がくっついて来てしまいます。

```
const scrollX = window.pageXOffset;
const scrollY = window.pageYOffset;
```
スクロールをしたときにも半透明の位置がちゃんと認識されるようできます

```
const offsetX = rectObj
        ? event.pageX - (rectObj.left + scrollX)
        : event.pageX;
const offsetY = rectObj
        ? event.pageY - (rectObj.top + scrollY)
        : event.pageY;
```
半透明を出す位置をここで決めています

## Styleのコード

```scss:ImageZoom.vue
<style lang="scss" scoped>
.product-image-content {
  width: 100%;
  @screen xl {
    width: 490px;
    height: 550px;
  }
}
img {
  &.center {
    width: 100%;
    @screen xl {
      width: 490px;
      height: auto;
    }
  }
}
.list {
  @apply w-full mt-2 mx-auto;
  ul {
    @apply overflow-x-auto whitespace-nowrap;
    li {
      @apply inline-block;
    }
  }
  img {
    width: 70px;
    height: 102px;
    @apply object-cover mx-1;
  }
  @screen lg {
    width: 490px;
    @apply mt-2 mx-auto;
    ul {
      @apply overflow-x-visible whitespace-pre-wrap px-2;
      li {
        @apply inline-block;
      }
    }
    img {
      width: 70px;
      height: 102px;
      @apply ml-0 mr-2 cursor-pointer;
    }
  }
}
.previewer-area {
  @apply absolute top-0 z-10;
}
.previewer-inner {
  @apply h-full;
}
.previewer {
  @apply absolute right-0 top-0 left-0 bottom-0;
}
.zoom-area {
  width: 250px;
  height: 350px;
  cursor: crosshair;
  @apply absolute bg-white opacity-50;
}
</style>
```

CSSの方は特にポイントとかないです！
ズームしているときの可変な部分はScript内に記述するので特別なことはしていないです。



このコンポーネントを呼びたい箇所からpropsで画像を渡して使用すると、上部のGIFのように動きます。

Githubはこちらです！

[vue3-image-zoom](https://github.com/maa-na/vue3-image-zoom)


ご覧いただきありがとうございました！