JavaScriptで商品画像の拡大プレビュー機能の実装

>この記事は[VASILY DEVELOPERS BLOG](http://tech.vasily.jp/entry/zoom_preview)にも同じ内容で投稿しています。よろしければ他の記事もご覧ください。

# 概要
商品画像の拡大プレビュー機能を実装したので、その実装方法について紹介します。

![zoom.gif](https://qiita-image-store.s3.amazonaws.com/0/54957/99b37f04-838e-327d-f123-f7e6c207d6ce.gif)

# 仕様
今回、機能を実装する上で満たさなければいけなかった仕様です。

- 画像をマウスオーバーした際に横に拡大プレビューを表示する
- 元の画像はカルーセルで表示されている
- 画像の大きさは予めわからない
- 対応ブラウザはPCのモダンブラウザ（IE11, Edge, Firefox最新版, Chrome最新版, Safari最新版）

# 実装方法
次の順序で実装について説明していきます。

1. ルーペの表示
2. 拡大プレビューエリア
3. 表示・非表示の切り替え
4. カーソル移動に合わせたプレビュー箇所の変更

## ルーペの表示実装
<img width="997" alt="loupe.png" src="https://qiita-image-store.s3.amazonaws.com/0/54957/a264d938-2f46-0188-9ac2-95e460a84477.png">

まずは、ルーペの表示部分を実装します。ルーペは商品画像に重なるように表示する必要があります。そのため、商品画像に対して相対的に位置を指定したいところです。しかし、imgタグはタグを内包できないので、画像と同じ大きさを持つdivで一段くくり、それに対して相対位置を指定します。

```html
<div class="m-lens-container">
  <img alt="商品タイトル" src="https://dummyimage.com/600x400/000/fff">
  <div class="m-lens"></div>
</div>
<div class="m-lens-container">
  <img src="https://dummyimage.com/400x600/000/fff">
  <div class="m-lens"></div>
</div>
```

```css
.m-lens-container {
  display: inline-block;
  position: relative;
  margin-
}
.m-lens {
  position: absolute;
  top: 50px; /* JSで適切な値を設定する */
  left: 30px; /* JSで適切な値を設定する */
  z-index: 2;
  background: #f57716;
  opacity: 0.3;
  height: 172px;
  width: 172px;
}
.m-lens-container img {
  max-height: 344px;
  max-width: 344px;
}
```
<img width="637" alt="loupe1.png" src="https://qiita-image-store.s3.amazonaws.com/0/54957/e058b766-88bb-3bf1-9a26-be85f0034e12.png">

これでルーペ部分を画像に重ねて表示することはできました。しかし、完成形と比較してわかるように画像が縦横中央揃えになっていません。そこで次に`display: table`を使って画像の縦横中央揃えを実現します。

```html
<ul class="slides">
  <li class="slide">
    <div class="cell">
      <div class="m-lens-container">
        <img src="https://dummyimage.com/600x400/000/fff">
        <div class="m-lens"></div>
      </div>
    </div>
  </li>
  <li class="slide">
    <div class="cell">
      <div class="m-lens-container">
        <img src="https://dummyimage.com/400x600/000/fff">
        <div class="m-lens"></div>
      </div>
    </div>
  </li>
</ul>
```
```css
.slides {
  display: flex; /* カルーセルで横並びにする必要があるため */
  justify-content: space-between; /* 解説用 */
  width: 700px; /* 解説用 */
}
.slide {
  display: table;
  background: #fff; /* 解説用 */
  text-align: center; /* 横に中央揃え */
  height: 344px;
  width: 344px;
}
.cell {
  display: table-cell;
  vertical-align: middle; /* 縦に中央揃え */
}
```
上のように記述することで、画像を縦横中央揃えにすることができました（一部、結果が見えやすい用に解説用のCSSを加えています）。
<img width="763" alt="loupe2.png" src="https://qiita-image-store.s3.amazonaws.com/0/54957/6fedf887-a50c-f121-3d6c-3c3f0c00eb07.png">


## 拡大プレビューエリアの実装
<img width="997" alt="zoom_area.png" src="https://qiita-image-store.s3.amazonaws.com/0/54957/b8ef31ff-91c2-abe8-9155-3a6bc164c059.png">

次に、拡大プレビューエリアの実装について説明します。ここでのポイントは、拡大プレビューエリアを画像の右横に他の要素に重なるように表示することと、拡大した画像を切り抜く必要があることです。ここでは、`position: absolute`を使って右横に配置し、`overflow: hidden`を使って切り抜きます。

```html
<div class="images">
  <div class="zoom-area active"><!-- JSでactiveを切り替える -->
    <img src="https://dummyimage.com/600x400/000/fff" />
  </div>
  <div class="slides-container">
    <ul class="slides">
      ...
    </ul>
  </div>
</div>
```
```css
.images {
  position: relative;
}
.slides-container { /* カルーセル表示領域 */
  width: 344px;
  overflow: hidden;
}
.zoom-area {
  display: none;
  position: absolute;
  top: 0;
  left: 369px;
  border: 1px solid #ccc;
  height: 520px;
  width: 520px;
  overflow: hidden;
}
.zoom-area.active {
  display: block;
}
.zoom-area img {
  width: 1040px; /* JSで適切な値を設定する */
  margin-top: -30px; /* JSで適切な値を設定する */
  margin-left: -60px; /* JSで適切な値を設定する */
}
```

`position: absolute`を使って画像の右横に設置したいですが、slides-container内にzoom-areaを入れるのは不自然なので、ここでもdiv (images)で一段くくり、それに対し`position: relative`を設定し、プレビューエリアの表示位置の基準値とします。そして、zoom-areaに`position: absolute`を設定し、leftを使って必要な量のオフセットを水平方向に設定します。
次に、画像の切り抜きについてです。これは、zoom-area内に拡大後のサイズの画像を入れて、エリアから溢れた部分を非表示にすることで実現できます。溢れた部分の非表示は`overflow: hidden`で、表示位置の指定はネガティブマージンで実装できます。

<img width="947" alt="zoom_area1.png" src="https://qiita-image-store.s3.amazonaws.com/0/54957/9b2ce5c3-7047-43ba-5d3d-13932da8ad9e.png">

後ほど解説しますが、拡大後の画像サイズやネガティブマージンの量の調節、zoom-areaのactiveの切り替えは、JS側で行う必要があります。

## 表示・非表示の切り替え
ここまでで、CSSによるレイアウトなどの設定はできたので、いよいよ動きの部分を作っていきます。
まずは、ルーペと拡大エリアの表示・非表示の切り替えから説明していきます。

```css
.m-lens {
  display: none;
}
.m-lens-container:hover .m-lens {
  display: block;
}
```

まず、CSSでm-lensに`display: none`を設定することでルーペをデフォルト非表示にします。そして、m-lens-containerのhover時に中のm-lensに`display: block`を設定することで、画像をホバーした際にルーペが表示されるようにします。

```javascript
(function(){
  var zoomArea = document.querySelector('.zoom-area');
  var zoomImage = zoomArea.querySelector('img');
  var size = 172;
  var scale = 520 / size;
  Array.prototype.forEach.call(document.querySelectorAll('.m-lens-container'), function(container){
    var lens = container.querySelector('.m-lens');
    var img = container.querySelector('img');
    container.addEventListener('mouseenter', function(){
      var image = container.querySelector('img');
      zoomArea.classList.add('active');
      zoomImage.setAttribute('src', image.src);
      zoomImage.style.width = (image.offsetWidth * scale) + 'px';
    });
    container.addEventListener('mouseleave', function(){
      zoomArea.classList.remove('active');
    });
  });
})();
```

次に、JSで画像のホバー時に拡大エリアを表示するようにします。拡大エリアを表示するにはzoom-areaにactiveのクラスを追加すればよいので、m-lens-containerのmouseenterイベントに対して、`zoomArea.classList.add('active')`の処理を結びつけます。このとき同時に拡大エリアに表示する画像のパスと拡大後のサイズの指定を行います。拡大後の画像のサイズは、左に実際に表示されているサイズに拡大率をかけることで求まります。また、拡大率は拡大エリアのサイズをルーペのサイズで割ることで求まります（ここでは簡単化のためにルーペと拡大エリアのサイズを直接指定していますが、汎用性を高めるならJSでサイズを取得してもよいと思います）。
そして、拡大エリアの非表示は、画像からカーソルが離れた際に、zoom-areaのactiveを解除すればよいので、m-lens-containerのmouseleaveイベントに対して、`zoomArea.classList.remove('active')`の処理を結びつけることで実装できます。

（注）拡大プレビューエリアの実装の説明時の例では、zoom-areaに対し、activeをデフォルトで指定していましたが、本来はデフォルトでactiveは不要なので削除の必要があります。

![active.gif](https://qiita-image-store.s3.amazonaws.com/0/54957/00d1506d-1647-408a-7680-f59698309662.gif)

## カーソル移動に合わせたプレビュー箇所の変更
最後に、カーソル移動に合わせたプレビュー箇所の変更を実装します。

```javascript
Array.prototype.forEach.call(document.querySelectorAll('.m-lens-container'), function(container){
  ...
  container.addEventListener('mousemove', function(e){
    var rect = container.getBoundingClientRect() ;
    var mouseX = e.pageX;
    var mouseY = e.pageY;
    var positionX = rect.left + window.pageXOffset;
    var positionY = rect.top + window.pageYOffset;
    var offsetX = mouseX - positionX;
    var offsetY = mouseY - positionY;
    var left = offsetX - (size / 2);
    var top = offsetY - (size / 2);

    lens.style.top = top + 'px';
    lens.style.left = left + 'px';
    zoomImage.style.marginLeft = -(left * scale) + 'px';
    zoomImage.style.marginTop = -(top * scale) + 'px';
  });
});
```

先程のJSにmouseenter, mouseleaveに加え、mousemoveのイベント処理を追加します。ここで必要な処理は、マウスカーソルの移動の度にその座標を取得し、ルーペの表示位置と拡大エリアのプレビュー箇所を計算し、指定することです。
座標の取得については、当初、`e.offsetX`と`e.offsetY`を用いる予定でしたが、要素が重なっているせいなのか、m-lens-containerではなくルーペの基準値からの距離を取得してしまったため、pageXとpageYを使う実装に変更しました。
pageXとpageYはページの左上からの距離を取得するので、m-lens-containerの左上からの距離に変換する必要があります。そのために、getBoudingClientRectとwindow.pageXOffset, window.pageYOffsetを使う必要があります。まず、`rect = container.getBoudingClientRect()`で、矩形オブジェクトを取得します。そして、`rect.left`と`rect.top`でウィンドウの左上からの距離をそれぞれ取得します。これはあくまで、ウィンドウで表示されている領域からの距離なので、これらの値とpageX, pageYから座標を計算してしまうと、ページがスクロールされている場合に、スクロール量の分だけ計算がずれてしまいます。
そこで、`window.pageXOffset`と`window.pageYOffset`を使って、スクロール量も計算に入れます。まとめると次のようになります。

```
コンテナの左上からの距離 = ページの左上からの距離 - スクロール量 - ウィンドウの左上からの距離
```

これでカーソル位置を計算することができましたが、このままの値をルーペの表示位置にしてしまうとカーソルがルーペの左上にきてしまいます。そこで、ルーペのサイズの半分を、計算結果から引いてあげます。これでカーソルを中心にルーペが表示できます。
まだ、拡大エリアのプレビューの表示箇所の変更の処理が残っていますが、ここまでくれば後は簡単です。コンテナの左上からのルーペまでの距離と拡大エリアで非表示にしたい左上の領域の比率は同じなので、先程計算したコンテナの左上からのルーペの距離を拡大比率でかけたものをネガティブマージンに設定するだけで処理は完了です。

![zoom1.gif](https://qiita-image-store.s3.amazonaws.com/0/54957/26c78524-16f7-3bd0-5d9e-16040cb76fcc.gif)

ここまでで拡大機能は一通りできましたが、上の動画を見てもらうと画像外の範囲もプレビューしてしまって使いづらいことがわかると思います。
そこで、次に、プレビューエリアの限界値の設定を行います。

```javascript
var xmax, ymax;
img.addEventListener('load', function(){
   xmax = img.offsetWidth - size;
   ymax = img.offsetHeight - size;
});
container.addEventListener('mousemove', function(e){
  ...
  var left = offsetX - (size / 2);
  var top = offsetY - (size / 2);

  if(left > xmax){
    left = xmax;
  }
  if(top > ymax){
    top = ymax;
  }
  if(left < 0){
    left = 0;
  }
  if(top < 0){
    top = 0;
  }
  lens.style.top = top + 'px';
  lens.style.left = left + 'px';
  zoomImage.style.marginLeft = -(left * scale) + 'px';
  zoomImage.style.marginTop = -(top * scale) + 'px';
});
```

画像の読み込みが終わったタイミングで画像の表示サイズを取得します。そして、ルーペの座標は左上を基準としていることを考慮すると、表示サイズからルーペの大きさを引いた値がルーペの座標の最大値になることがわかります。
後は、ルーペの表示位置の計算結果が限界値を超えた際に、限界値にする処理を入れればルーペが画像をはみ出ることはなくなります。

![zoom2.gif](https://qiita-image-store.s3.amazonaws.com/0/54957/17c8515b-f740-d40c-b139-e9b0daee43a9.gif)

以上が今回実装した拡大プレビュー機能の実装方法です。

今回、解説時に作ったサンプルをgistに公開してあるので、コード全体を見たい方は[こちら](https://gist.github.com/AmatsukiKu/7a75ba8958e38c8e6a1186f09dc0d209)を参照してください。