画像をドラッグ&ドロップで追加

現在RubyとJavaScriptを用いて記事投稿サイトを作成しています。
今回は新規投稿画面にて
#####「画像をドラッグ&ドロップで追加」
を実装していきたいと思います。

###■仕様等
・ActiveStorageを利用
・jQueryは非使用
・画像をドロップするとプレビュー画像が表示される

###■完成後の動作とコード
[![Image from Gyazo](https://i.gyazo.com/6b0cf8529392bf4792a3269fbcbdecf1.gif)](https://gyazo.com/6b0cf8529392bf4792a3269fbcbdecf1)

```javascript:.js
//⓪　全体の処理を行う条件と要素を取得
if (document.URL.match( /new/ ) || document.URL.match( /edit/ )) {
  document.addEventListener('DOMContentLoaded', function(){
    const body = document.querySelector('body');
    const imageList = document.getElementById("image-list");
    const inputList = document.getElementById("input-list");
    const firstInput = document.getElementById("input_article_image_-1");
    firstInput.setAttribute('class', 'focus_input');
    const dropText = document.getElementById('drop-text');

//②　ドロップされた画像のプレビューと、次の画像に対応するinput要素の生成
//②-1　div要素の生成
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', 'image-element');
      let imageElementNum = document.querySelectorAll('.image-element').length

//②-2　プレビュー(img要素)の生成
      const blobImage = document.createElement('img');
      blobImage.setAttribute('src', blob);
      blobImage.setAttribute('class', 'blob-image');
      if (document.getElementsByClassName('article_image_input').length == 0 ) {
        blobImage.setAttribute('id', 'article_image_-1');
       } else {
        blobImage.setAttribute('id', `article_image_${imageElementNum - 1}`);
      }

//②-3　input要素の生成
      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `input_article_image_${imageElementNum}`);
      inputHTML.setAttribute('name', 'article[images][]');
      inputHTML.setAttribute('class', 'article_image_input')
      inputHTML.setAttribute('type', 'file');
      inputHTML.classList.add('focus_input');

//②-4　生成した各要素を挿入
      imageElement.appendChild(blobImage);
      inputList.appendChild(inputHTML)
      imageList.appendChild(imageElement);

//②-5 入力済みのinput要素を非表示
      if (document.getElementsByClassName('article_image_input').length == 1 ) {
        firstInput.setAttribute('style', 'visibility: hidden; color: rgba(0,0,0,0);');
        firstInput.classList.remove('focus_input');
      } else {
        document.getElementById(`input_article_image_${imageElementNum - 1}`).setAttribute('style', 'visibility: hidden; color: rgba(0,0,0,0);');
        document.getElementById(`input_article_image_${imageElementNum - 1}`).classList.remove('focus_input');
      }

//③　プレビューに対するアクション
//③-1　カーソルを乗せた時の処理
      blobImage.addEventListener('mouseover', () => {
        blobImage.setAttribute('style', "opacity: 0.4;")
      });

      blobImage.addEventListener('mouseout', () => {
        blobImage.removeAttribute('style', "opacity: 0.4;")
      });

//③-2　クリックした時の処理
      blobImage.addEventListener('click', (e) => {
        const targetInput = document.getElementById(`input_${blobImage.id}`);
        console.log(targetInput.id)
        targetInput.value = "";
        if (!(targetInput.id == 'input_article_image_-1')) {
          targetInput.remove();
        };
        blobImage.parentNode.setAttribute('style', 'display: none;')
        blobImage.remove();
        if (document.getElementsByClassName('article_image_input').length == 1 && firstInput.value == "" ) {
          firstInput.nextElementSibling.remove();
          firstInput.removeAttribute('style', 'visibility: hidden;');
          firstInput.setAttribute('class', 'focus_input');
          dropText.removeAttribute('style', 'display: none;');
        }
      });
     };

//①　画像をドラッグ&ドロップした際の処理
//①-1　ドラッグしている時の処理
    body.addEventListener('dragover', (e) => {
      e.preventDefault();
      imageList.setAttribute('style', 'background-color: rgba(206, 207, 196, 0.4); border: 3px solid lightblue;')
    });
    
    body.addEventListener('dragleave', (e) => {
      e.preventDefault();
      imageList.removeAttribute('style', 'background-color: rgba(206, 207, 196, 0.4); border: 3px solid lightblue;')
    });

//①-2　ドロップした時の処理
      body.addEventListener('drop', (e) => {
        e.preventDefault();
        imageList.removeAttribute('style', 'background-color: rgba(206, 207, 196, 0.4); border: 3px solid lightblue;')
        dropText.setAttribute('style', 'display: none;');
        const input = document.querySelector("input[class*='focus_input']");
        input.files = e.dataTransfer.files;
        const blob = window.URL.createObjectURL(input.files[0]);
        createImageHTML(blob);
      });
  });
};
```

```HTML:.html
      <%= form_with model: @article, url: articles_path, local: true, class: "new-article-form" do |f| %>
        <%= render partial: "shared/error_message", locals: { model: f.object },  class: "error-messages"%>
        <div id="image-area">
          <div id="image-list">
            <span id="drop-text">添付する画像をドロップ</span>
          </div>
          <div id="input-list">
            <%= f.file_field :images, name: "article[images][]", id: "input_article_image_-1", class: "new-article-image" %>
          </div>
        </div>
```

###■実装手順（コード内の番号と対応）
#####⓪ 全体の処理を行う条件と要素を取得
#####① 画像をドラッグ&ドロップした際の処理
　1- ドラッグしている時の処理
　2- ドロップした時の処理
#####② ドロップされた画像のプレビューと、次の画像に対応するinput要素の生成
　1- div要素を生成する
　2- プレビュー(img要素)を生成
　3- input要素を生成
　4- 生成した各要素を挿入
　5- 入力済みのinput要素を非表示
#####③ プレビューに対するアクション
　1- カーソルを乗せた時の処理
　2- クリックした時の処理

###■⓪ 全体の処理を行う条件と要素を取得
```javascript:⓪
if (document.URL.match( /new/ ) || document.URL.match( /edit/ )) {
  document.addEventListener('DOMContentLoaded', function(){
    const body = document.querySelector('body');
    const imageList = document.getElementById("image-list");
    const inputList = document.getElementById("input-list");
    const firstInput = document.getElementById("input_article_image_-1");
    firstInput.setAttribute('class', 'focus_input');
    const dropText = document.getElementById('drop-text');
```
**1行目/2行目**
処理全体を実行する条件を記述しています。
「新規投稿と編集ページにおいてHTMLを読み込んだ後に処理を行う」という内容です。

**3行目〜8行目**
ここでは今回利用する要素を取得しています。
・`body` - ページ全体
・`image-list`（div要素） - img要素(プレビュー)を格納する要素
・`input-list`（div要素） - input要素(ファイル選択ボタン)を格納する要素
・`firstInput`（div要素） - 最初に存在するinput要素。class属性に「focus_input」を設定
・`drop-text`（span要素） - 「添付する画像をドロップ」を表示する要素

###■① 画像をドラッグ&ドロップした際の処理
#####①-1 ドラッグしている時の処理
#####①-2 ドロップした時の処理
####①-1 ドラッグしている時の処理
```javascript:①-1
    body.addEventListener('dragover', (e) => {
      e.preventDefault();
      imageList.setAttribute('style', 'background-color: rgba(206, 207, 196, 0.4); border: 3px solid lightblue;')
    });
    
    body.addEventListener('dragleave', (e) => {
      e.preventDefault();
      imageList.removeAttribute('style', 'background-color: rgba(206, 207, 196, 0.4); border: 3px solid lightblue;')
    });
```
**1行目**
body領域にファイルがドラッグされた際のイベントを記述していきます。
**2行目**
デフォルトの処理をキャンセルしています。
**3行目**
`image-list`にstyle属性を付与しています。
ファイルがドラッグされるとimage-list領域の背景色が水色になり枠線が表示されます。

**6行目**
ドラッグが解除された時の処理を記述しています。
**7行目**
デフォルトの処理をキャンセルしています。
**8行目**
3行目で設定したimage-listのstyle属性を削除しています。

####①-2　ドロップした時の処理
```javascript:①-2
      body.addEventListener('drop', (e) => {
        e.preventDefault();
        imageList.removeAttribute('style', 'background-color: rgba(206, 207, 196, 0.4); border: 3px solid lightblue;')
        dropText.setAttribute('style', 'display: none;');
        const input = document.querySelector(".focus_input");
        input.files = e.dataTransfer.files;
        const blob = window.URL.createObjectURL(input.files[0]);
        createImageHTML(blob);
      });
```
**1行目**
body領域にファイルがドロップされた際のイベントを記述していきます。
**2行目**
デフォルトの処理をキャンセルしています。
**3行目**
`image-list`にstyle属性を削除しています。
image-list領域の背景色と枠線を削除します。
**4行目**
「添付する画像をドロップ」を非表示化しています。
**5行目**
「class=`'focus_input'`」が設定されているinput要素（⓪の7行目）を取得しています。
**6行目**
ドロップされた画像を取得した`input要素.files`に代入しています
**7行目**
`input.files`の画像情報をURL化して`変数blob`に代入しています
**8行目**
後述の関数の実引数に変数blobを設定

###■② ドロップされた画像のプレビューと、次の画像に対応するinput要素の生成
#####②-1 div要素を生成する
#####②-2 プレビュー（img要素）を生成
#####②-3 input要素を生成
#####②-4 生成した各要素を挿入
#####②-5 入力済みのinput要素を非表示
####①-1 div要素を生成する
画像が二枚目、三枚目と選択される度に生成される要素は以下の3つです。
・div要素（img要素の親要素）
・img要素（プレビュー画像）
・input画像（入力情報）

div要素を生成する理由は、
各img要素、各input要素に**重複しない番号(ID)**を付与する為です。
この後にプレビュー画像であるimg要素を生成しますが、その親要素として生成します↓

```HTML:例
<div id='image-element' class='image-element'>
  <img> 
</div>
```
↓選択解除によりimg要素(プレビュー画像)が消えても

```HTML:例
<div id='image-element' class='image-elemant'>
</div>
```
このように親要素のdiv要素が残るようにしてあげます。
このdiv要素の`length`をもとにimg要素やinput要素のid属性に番号を付与すれば、
idの重複を避けることができます。(例：article_image__1、article_image_2...)
<br>

```javascript:②-1
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', 'image-element');
      let imageElementNum = document.querySelectorAll('.image-element').length
```
**1行目**
ドロップされた画像に対応するプレビュー及び、
次にドロップされるであろう画像に対応するinput要素を生成するための関数を宣言しています。
**2行目**
div要素を生成しています。
**3行目**
class属性を追加しています。
**4行目**
このdiv要素の総数を取得しています。

####②-2　プレビュー（img要素）を生成
```javascript:②-2
      const blobImage = document.createElement('img');
      blobImage.setAttribute('src', blob);
      blobImage.setAttribute('class', 'blob-image');
      if (document.getElementsByClassName('article_image_input').length == 0 ) {
        blobImage.setAttribute('id', 'article_image_-1');
      } else {
        blobImage.setAttribute('id', `article_image_${imageElementNum - 1}`);
      }
```
**1行目**
img要素を生成しています。
**2行目**
src属性に**①-2の7行目で作成したURL**を設定しています。
**3行目**
class属性を追加しています。
**4行目~7行目**
id属性を追加しています（後述の、**追加のinput要素**が存在するか否かで条件分岐）
- 追加のinput要素が**存在しない時**(4行目)
　`article_image-1`を設定(5行目)
- 追加のinput要素が**存在する時**(6行目）
　`article_image_${imageElementNum - 1}`を設定（7行目）

####②-3　input要素を生成
```javascript:②-3
      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `input_article_image_${imageElementNum}`);
      inputHTML.setAttribute('name', 'article[images][]');
      inputHTML.setAttribute('class', 'article_image_input')
      inputHTML.classList.add('focus_input');
      inputHTML.setAttribute('type', 'file');

```
**1行目**
次にドロップされるであろう画像に対応するinput要素を生成しています。
**2行目**
id属性を設定しています。
次の画像に対応する為、**次の番号を付与**（=「-1」が付かない）
**3行目**
name属性を設定しています。
**4行目**
class属性を設定しています。
**5行目**
class属性を追加しています。
**入力待ちのinput要素であることを識別する為**の値
**6行目**
type属性を設定しています。

####②-4　生成した各要素を挿入
```javascript:②-4
      imageElement.appendChild(blobImage);
      imageList.appendChild(imageElement);
      inputList.appendChild(inputHTML)

```
**1行目**
img要素をdiv要素に挿入しています。
**2行目**
div要素を`image-list`に挿入しています。
**3行目**
input要素を`input-list`に挿入しています。

####②-5　入力済みのinput要素を非表示
```javascript:②-5
      if (document.getElementsByClassName('article_image_input').length == 1 ) {
        firstInput.setAttribute('style', 'visibility: hidden; color: rgba(0,0,0,0);');
        firstInput.classList.remove('focus_input');
      } else {
        document.getElementById(`input_article_image_${imageElementNum - 1}`).setAttribute('style', 'visibility: hidden; color: rgba(0,0,0,0);');
        document.getElementById(`input_article_image_${imageElementNum - 1}`).classList.remove('focus_input');
      }
```
**1行目〜6行目**
**追加のinput要素が1つの場合**の処理を記述しています（= 最初の画像がドロップされた直後）
- 最初のinput要素を非表示（2行目）
- 最初のinput要素から**入力待ちを意味する「`class:focus_input`」を削除**（3行目）

**追加のinput要素が2つ以上の場合**の処理を記述しています（4行目）
- 直前のinput要素に対して上と同様の処理（5行目/6行目）

###■③　プレビューに対するアクション
#####③-1 カーソルを乗せた時の処理
#####③-2 クリックした時の処理
####③-1　カーソルを乗せた時の処理
```javascript:③-1
      blobImage.addEventListener('mouseover', () => {
        blobImage.setAttribute('style', "opacity: 0.4;")
      });

      blobImage.addEventListener('mouseout', () => {
        blobImage.removeAttribute('style', "opacity: 0.4;")
      });
```
プレビューにカーソルを乗せた際に、プレビューを半透明化しています。

####③-2　クリックした時の処理
```javascript:③-2
      blobImage.addEventListener('click', () => {
        const targetInput = document.getElementById(`input_${blobImage.id}`);
        targetInput.value = "";
        if (!(targetInput.id == 'input_article_image_-1')) {
          targetInput.remove();
        };
        blobImage.parentNode.setAttribute('style', 'display: none;')
        blobImage.remove();
        if (document.getElementsByClassName('article_image_input').length == 1 && firstInput.value == "" ) {
          firstInput.nextElementSibling.remove();
          firstInput.removeAttribute('style', 'visibility: hidden;');
          firstInput.setAttribute('class', 'focus_input');
          dropText.removeAttribute('style', 'display: none;');
        }
      });
```
**1行目**
プレビューがクリックされた際のイベントを記述していきます。
**2行目/3行目**
クリックされた**img要素に対応するinput要素**を取得して代入しています。（2行目）
そのinput要素の値を削除しています。（3行目）
**4行目〜6行目**
最初のinput要素ではない場合（=**2つ目以降のinput要素**の場合）、（4行目）
そのinput要素自体を削除しています。（5行目）
**7行目**
img要素の親要素であるdiv要素を非表示にしています。
**8行目**
img要素を削除しています。
**9行目〜14行目**
以降はプレビュー画像をクリックして選択を解除した際、
それが**最後の一枚**で、画像が全て解除された場合の処理を記述しています。
求める処理結果としては、最初のinput要素を再表示させて入力待ちの状態に戻し、
「添付する画像をドロップ」メッセージを再表示する事です。

追加されたinput要素が一つ且つ最初のinput要素が空の場合、（9行目）
追加されたinput要素を削除しています。（10行目）
非表示となっていた最初のinput要素を表示しています。(11行目)
最初のinput要素に**入力待ちを意味する**「`class:focus_input`」を追加しています。（12行目）
「添付する画像をドロップ」を表示しています。（13行目）

以上で完成です。

####参考記事
[https://kuwk.jp/blog/dd/](https://kuwk.jp/blog/dd/)