画像ポップアップギャラリーをJavaScriptで実装

# できあがり

![screen_animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/e2754b9f-7c93-8fae-51f7-266366614a07.gif)

# きっかけ

[magnific-popup](https://dimsemenov.com/plugins/magnific-popup/)を使おうと思ったのですが

* 次の画像への**遷移のときのフェードイン、フェードアウトが設定できない**

が解決できなかったので、自分で作ることにしました。

（ポップアップを開くとき、閉じるときのフェードイン、フェードアウトはmagnific-popupでも可です）

# 要点

* ポップアップを開くとき、次の画像に遷移するとき、ポップアップを閉じるときの**フェードイン**、**フェードアウトエフェクトが設定可**。
* **縦長の画像**は**縦幅が画面サイズ**になり、**横長の画像**は**横幅が画面サイズ**になる。
* ポップアップ画像の下に**説明文を表示**できる。
* ポップアップ画像の右半分、左半分それぞれをクリックすると、次の画像、前の画像に遷移できる。
* キーボードの右、左、ESCで前後への遷移、閉じる動作が可。
* JQueryは使わない。

# ソース

```css gallery-popup.css
.gallery-item {
  cursor: pointer;
}
.gallery-image {
  display: none;
}
.gallery-description {
  display: none;
}

@keyframes gallery-fade-in-frame {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
@keyframes gallery-fade-out-frame {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
@keyframes gallery-fade-in-05-frame {
  from {
    opacity: 0;
  }
  to {
    opacity: 0.5;
  }
}
@keyframes gallery-fade-out-05-frame {
  from {
    opacity: 0.5;
  }
  to {
    opacity: 0;
  }
}

.gallery-fade-in-out {
  animation-duration: 600ms;
  animation-timing-function: linear;
  animation-fill-mode: both;
}
.gallery-popup-botton {
  display: flex;
  align-items: center;
  cursor: pointer;
  color: #aaa;
}
.gallery-popup-botton:hover {
  color: #555;
}
```

```js gallery-popup.js
let openingPopup = false;
let closingPopup = false;
let creatingContainer = false;
let removingContainer = false;

let currenrContainer;
let currentKeybordEventListener = null;

const background = document.createElement("div");
background.style.display = "none";
background.style.position = "fixed";
background.style.left = "0";
background.style.top = "0";
background.style.zIndex = "2001";
background.style.width = "100%";
background.style.height = "100%";
background.style.backgroundColor = "#000";
background.classList.add("gallery-fade-in-out");
document.body.append(background);

const createContainer = (contentIndex) => {
  currenrContainer = document.createElement("div");
  currenrContainer.style.display = "grid";
  currenrContainer.style.justifyContent = "center";
  currenrContainer.style.alignContent = "center";
  currenrContainer.style.position = "fixed";
  currenrContainer.style.left = "0";
  currenrContainer.style.top = "0";
  currenrContainer.style.margin = "4px";
  currenrContainer.style.width = "calc(100% - 8px)";
  currenrContainer.style.height = "calc(100% - 8px)";
  currenrContainer.style.zIndex = "2002";
  currenrContainer.style.gridTemplateColumns = "40px minmax(0, auto) 40px";
  currenrContainer.style.gridTemplateRows =
    "40px minmax(0, min-content) 10px min-content 30px";

  const gridBackground = document.createElement("div");
  gridBackground.style.backgroundColor = "white";
  gridBackground.style.gridRow = "1 / 6";
  gridBackground.style.gridColumn = "1 / 4";
  currenrContainer.append(gridBackground);

  document.body.append(currenrContainer);
  const galleryImage = garralyItems[contentIndex]
    .getElementsByClassName("gallery-image")[0]
    .cloneNode(true);
  galleryImage.style.display = "block";
  galleryImage.style.gridRow = "2";
  galleryImage.style.gridColumn = "2";
  galleryImage.style.maxWidth = "100%";
  galleryImage.style.maxHeight = "100%";
  galleryImage.style.margin = "auto";
  currenrContainer.append(galleryImage);

  const cancelBotton = document.createElement("div");
  cancelBotton.innerHTML = "&#10006;";
  cancelBotton.style.gridRow = "1";
  cancelBotton.style.gridColumn = "3";
  cancelBotton.style.justifyContent = "center";
  cancelBotton.style.marginRight = "5px";
  cancelBotton.style.marginTop = "5px";
  cancelBotton.style.fontSize = "15px";
  cancelBotton.classList.add("gallery-popup-botton");
  cancelBotton.onclick = closePopup;
  currenrContainer.append(cancelBotton);

  const leftArrow = document.createElement("div");
  leftArrow.innerHTML = "&#10094;";
  leftArrow.innerHTML = "<img src='left-arrow.png' style='width: 20px'>";
  leftArrow.style.marginLeft = "15px";
  leftArrow.style.marginRight = "auto";
  leftArrow.style.gridRow = "2";
  leftArrow.style.gridColumn = "1 / 4";
  leftArrow.style.width = "50%";
  leftArrow.style.fontSize = "20px";
  leftArrow.style.justifyContent = "start";
  leftArrow.classList.add("gallery-popup-botton");
  currenrContainer.append(leftArrow);

  const rightArrow = document.createElement("div");
  rightArrow.innerHTML = "&#10095;";
  rightArrow.style.gridRow = "2";
  rightArrow.style.gridColumn = "1 / 4";
  rightArrow.style.width = "50%";
  rightArrow.style.fontSize = "20px";
  rightArrow.style.justifyContent = "end";
  rightArrow.style.marginLeft = "auto";
  rightArrow.style.marginRight = "15px";
  rightArrow.classList.add("gallery-popup-botton");
  currenrContainer.append(rightArrow);

  let descriptions = garralyItems[contentIndex].getElementsByClassName(
    "gallery-description"
  );
  let galleryDescription;
  if (descriptions.length > 0) {
    galleryDescription = descriptions[0].cloneNode(true);
    galleryDescription.style.display = "block";
    galleryDescription.style.gridRow = "4";
    galleryDescription.style.gridColumn = "2";
    galleryDescription.style.maxWidth = "100%";
    galleryDescription.style.maxHeight = "100%";
    currenrContainer.append(galleryDescription);
  } else {
    galleryDescription = null;
  }

  const goToIndexFromCurrentContainer = (index) => {
    if (creatingContainer || removingContainer) {
      return;
    }

    // valid_index is in the range between 0 and length - 1
    let valid_index = index % garralyItems.length;
    if (valid_index < 0) {
      valid_index = garralyItems.length + valid_index;
    }

    removeContainer(currenrContainer, currentKeybordEventListener);
    createContainer(valid_index);
  };

  leftArrow.onclick = () => {
    goToIndexFromCurrentContainer(contentIndex - 1);
  };

  rightArrow.onclick = () => {
    goToIndexFromCurrentContainer(contentIndex + 1);
  };

  currenrContainer.onclick = (event) => {
    if (
      event.target != gridBackground &&
      event.target != cancelBotton &&
      event.target != leftArrow &&
      event.target != galleryImage &&
      event.target != rightArrow &&
      (galleryDescription == null || event.target != galleryDescription)
    ) {
      closePopup();
    }
  };

  currentKeybordEventListener = (event) => {
    if (event.key == "ArrowLeft") {
      goToIndexFromCurrentContainer(contentIndex - 1);
    } else if (event.key == "ArrowRight") {
      goToIndexFromCurrentContainer(contentIndex + 1);
    } else if (event.key == "Escape") {
      closePopup();
    }
  };

  document.addEventListener("keydown", currentKeybordEventListener);

  currenrContainer.classList.add("gallery-fade-in-out");

  document.body.append(currenrContainer);

  creatingContainer = true;
  currenrContainer.onanimationend = () => {
    creatingContainer = false;
  };

  currenrContainer.style.animationName = "gallery-fade-in-frame";
};

const removeContainer = (container, keybordEvenetListner) => {
  removingContainer = true;
  container.onanimationend = () => {
    container.remove();
    removingContainer = false;
  };
  container.style.animationName = "gallery-fade-out-frame";

  document.removeEventListener("keydown", keybordEvenetListner);
};

const openPupup = (contentIndex) => {
  if (openingPopup || closingPopup || creatingContainer || removingContainer) {
    return;
  }

  background.style.display = "block";

  openingPopup = true;
  background.onanimationend = () => {
    openingPopup = false;
  };

  background.style.animationName = "gallery-fade-in-05-frame";

  createContainer(contentIndex);
};

const closePopup = () => {
  if (openingPopup || closingPopup || creatingContainer || removingContainer) {
    return;
  }

  closingPopup = true;
  background.onanimationend = () => {
    background.style.display = "none";
    closingPopup = false;
  };

  background.style.animationName = "gallery-fade-out-05-frame";
  removeContainer(currenrContainer, currentKeybordEventListener);
};

background.onclick = closePopup;

const garralyItems = [];
[...document.getElementsByClassName("gallery-item")].forEach((item, index) => {
  item.onclick = () => {
    openPupup(index);
  };
  garralyItems.push(item);
});
```

# 使い方

以下のようなhtmlを作成し、同じフォルダに`gallery-popup.css`、`gallery-popup.js`（それぞれ[ソース](#ソース)の節に記載しています）を配置します。

```html
<head>
  <meta name="viewport" content="width=device-width" />
  <link rel="stylesheet" href="gallery-popup.css" />
  <script type="module" src="gallery-popup.js"></script>
</head>
<body>
  <ul>
    <li class="gallery-item">
      Image 1
      <img class="gallery-image" src="image_1.jpg" />
      <div class="gallery-description">First image.</div>
    </li>

    <li class="gallery-item">
      Image 2
      <img class="gallery-image" src="image_2.jpg" />
      <div class="gallery-description">Second image.</div>
    </li>

    <li class="gallery-item">
      Image 3
      <img class="gallery-image" src="image_3.jpg" />
      <div class="gallery-description">Third image.</div>
    </li>
  </ul>
</body>
```

## head内

以下2行が必要です:

```html
  <link rel="stylesheet" href="gallery-popup.css" />
  <script type="module" src="gallery-popup.js"></script>
```

## 1アイテム単位

1アイテム（リンク、ポップアップ画像、説明文のセット）毎に以下の様な記述が必要です:

```html
    <li class="gallery-item">
      Image 1
      <img class="gallery-image" src="image_1.jpg" />
      <div class="gallery-description">First image.</div>
    </li>
```

`gallery-item`クラスをつけたタグでポップアップを開くボタンになる部分（上例ではImage 1という文字列だが、imgタグなどより複雑な構造でも可）を挟みます。

さらにこの中に`gallery-image`クラスを設定したimgタグを含めます。これはポップアップ時に初めて表示されます。

また、`gallery-description`クラスを設定したタグがあれば、それもポップアップ時に表示されます。（これがなければ、ポップアップ時は画像のみ表示されます）

## フェードイン、フェードアウト時間の調整

cssファイル内、`gallery-fade-in-out`クラスの`animation-duration`で調整できます:

```css
.gallery-fade-in-out {
  animation-duration: 600ms;
  animation-timing-function: linear;
  animation-fill-mode: both;
}
```

## ボタンを画像にしたい場合

例えば左ボタンを変えたい場合は

```js
  leftArrow.innerHTML = "&#10094;";
  leftArrow.style.marginLeft = "15px";
```

を以下のように変えます。（`marginLeft`と`width`は見ながら調整します）

```js
  leftArrow.innerHTML = "<img src='left-arrow.png' style='width: 20px'>";
  leftArrow.style.marginLeft = "12px";
```

> `&#10094;`は左向き記号です。記号については[https://www.toptal.com/designers/htmlarrows/symbols/]()を参考にしました。

# メモ

## 次の画像へのフェードアウト、フェードイン遷移の実現

現在の画像をフェードアウトさせながら、次の画像をフェードインさせる必要があるので、同時に2つのポップアップを作ることで対応しました。

## animationかtransitionか

フェードイン、フェードアウトは[transition](https://www.w3schools.com/css/css3_transitions.asp)でも実装できます。

transitionを使う場合、下記のようにsetTimeout内（ただし、このsetTimeoutはウエイト時間0）で設定する必要がありました。この動作が少し不自然にも思えた（初見で読んだときにsetTimeoutの必要性が分かりにくい）ので、今回はanimationで実装しました。

```
      const p = document.createElement("p");
      p.textContent = "1";
      p.style.opacity = "0";
      p.style.transition = "opacity 2s";
      document.body.append(p);
      setTimeout(() => {
        p.style.opacity = "1";
      });
```

> animationは`@keyframes`を定義しないといけないことが少し面倒？ですが、そこは許容しました。

## ポップアップ時の画像サイズ調整

なるべく画像を大きく見せたいので、縦長画像は画面の縦幅に縦幅を合わせて、横長画像は画面横幅に合わせる方針にしました。

また、もともとの画像サイズが画面より小さいは場合は無理に引き延ばさないことにしました。

|画面より小さい場合|横が画面より大きい場合|縦が画面より大きい場合|
|:-:|:-:|:-:|
|![pupup_1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/8ee4e034-1e7b-7125-f7cb-ef76b9ba3265.png)|![pupup_2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/522c85fc-661b-0ada-bafe-c7338f40ec2f.png)|![pupup_3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/0c3d1b1f-0234-4f5f-693d-c4c48aae5cc7.png)|

[CSS grid layout](https://www.w3schools.com/css/css_grid.asp)を使って実装しまた:

![grid_layout.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560904/8ab07887-19ca-2f05-a93c-8492ac6bdd8d.png)