1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】プレビュー機能

Posted at

記事概要

画像ファイルのプレビューを表示させるアプリを作成する。

前提

  • Ruby on Railsでアプリケーションを作成している

サンプルアプリ(GitHub)

手順1(JSファイルの作成)

  1. app/javascriptディレクトリに、preview.jsを手動作成する
  2. config/importmap.rbを更新する
    importmap.rb
    # 最終行に追記
    pin "preview", to: "preview.js"
    
  3. app/javascript/application.jsを更新する
    application.js
    // 最終行に追記
    import "preview"
    

手順2(プレビュー画像を表示するスペースを作成)

  1. 特定のページでのみ、JSファイルの処理を行うように記述する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    });
    
  2. ブラウザ確認を行う
    1. フォームを配置するページでは、コンソールに「preview.jsが読み込まれました」と表示されることを確認する
    2. フォームを配置しないページでは、コンソールに何も表示されないことを確認する
  3. app/views/posts/_form.html.erbに、プレビュー画像を表示するスペースを作成する
    <%= form_with model: @post, id: 'new_post', local: true do |f| %>
    <%= render 'shared/error_messages', model: f.object %>
      <div class="message-field">
        <%= f.text_field :text, placeholder: 'type a message' %>
      </div>
      <div class="image-field">
        <div id="previews"></div><!-- 追加 -->
        <div class="click-upload">
          <%= f.file_field :image %>
        </div>
      </div>
      <div class="submit-btn">
        <%= f.submit '送信' %>
      </div>
    <% end %>
    
  4. プレビュー画像を表示するスペースのHTML要素を、JavaScript側で取得する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    });
    

手順3(選択した画像情報を取得する)

  1. input要素で値の変化が起きた際に呼び出される関数を定義する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        console.log("input要素で値の変化が起きました");
      });
    });
    
  2. ブラウザ確認を行う
    1. 「ファイルを選択」ボタンをクリックし、適当な画像ファイルを選択する
    2. コンソールに「input要素で値の変化が起きました」と表示されることを確認する
      Image from Gyazo
  3. inputの中にある画像を取得する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        console.log("input要素で値の変化が起きました");
        // 画像を取得
        console.log(e.target.files[0]);
      });
    });
    
    選択した画像ファイルは、発火したイベントeの中の、targetの中の、filesという配列に格納されている
  4. ブラウザ確認を行う
    1. 「ファイルを選択」ボタンをクリックし、適当な画像ファイルを選択する
    2. 画像ファイル情報が表示されることを確認する
      ※選択した画像ファイルの情報は、e.target.files[0]という形で、発火したEventハッシュに格納されているオブジェクトから取得できる
      Image from Gyazo
  5. 画像情報を、変数に格納する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        console.log("input要素で値の変化が起きました");
        // 画像を取得
        console.log(e.target.files[0]);
        // 取得した画像ファイルの情報を定義
        const file = e.target.files[0];
      });
    });
    
  6. 取得した画像情報のURLを生成する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        console.log("input要素で値の変化が起きました");
        // 画像を取得
        console.log(e.target.files[0]);
        // 取得した画像ファイルの情報を定義
        const file = e.target.files[0];
        // 取得した画像情報のURLを生成
        const blob = window.URL.createObjectURL(file);
        console.log(blob);
      });
    });
    
    選択された画像ファイルは、ブラウザ側がblobと呼ばれる形のデータで保持している。window.URL.createObjectURL(file)という記述により、上記データへとアクセスするURLを生成しているため、生成されたURLはblobという変数に代入
  7. ブラウザ確認を行う
    1. ブラウザをリロードする
    2. 「ファイルを選択」ボタンをクリックし、適当な画像ファイルを選択する
    3. コンソールにblob:http://localhost:3000/7cc5f1a1-f72e-4c2f-a684-88e443cdb85eのような値が表示されることを確認する
      Image from Gyazo

手順4(プレビュー機能を実装する)

  1. 画像のHTMLを作成する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        console.log("input要素で値の変化が起きました");
        // 画像を取得
        console.log(e.target.files[0]);
        // 取得した画像ファイルの情報を定義
        const file = e.target.files[0];
        // 取得した画像情報のURLを生成
        const blob = window.URL.createObjectURL(file);
        console.log(blob);
    
        // 画像を表示するためのdiv要素を生成
        const previewWrapper = document.createElement('div');
        previewWrapper.setAttribute('class', 'preview');
    
        // 表示する画像を生成
        const previewImage = document.createElement('img');
        previewImage.setAttribute('class', 'preview-image');
      });
    });
    
  2. 画像URLをimg要素のsrc属性に設定する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        console.log("input要素で値の変化が起きました");
        // 画像を取得
        console.log(e.target.files[0]);
        // 取得した画像ファイルの情報を定義
        const file = e.target.files[0];
        // 取得した画像情報のURLを生成
        const blob = window.URL.createObjectURL(file);
        console.log(blob);
    
        // 画像を表示するためのdiv要素を生成
        const previewWrapper = document.createElement('div');
        previewWrapper.setAttribute('class', 'preview');
    
        // 表示する画像を生成
        const previewImage = document.createElement('img');
        previewImage.setAttribute('class', 'preview-image');
        // 画像URLをimg要素のsrc属性に設定
        previewImage.setAttribute('src', blob);
      });
    });
    
  3. 生成した要素をブラウザに表示する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        console.log("input要素で値の変化が起きました");
        // 画像を取得
        console.log(e.target.files[0]);
        // 取得した画像ファイルの情報を定義
        const file = e.target.files[0];
        // 取得した画像情報のURLを生成
        const blob = window.URL.createObjectURL(file);
        console.log(blob);
    
        // 画像を表示するためのdiv要素を生成
        const previewWrapper = document.createElement('div');
        previewWrapper.setAttribute('class', 'preview');
    
        // 表示する画像を生成
        const previewImage = document.createElement('img');
        previewImage.setAttribute('class', 'preview-image');
        // 画像URLをimg要素のsrc属性に設定
        previewImage.setAttribute('src', blob);
    
        // 生成したHTMLの要素をブラウザに表示させる
        previewWrapper.appendChild(previewImage);
        previewList.appendChild(previewWrapper);
      });
    });
    
    previewListは、プレビュー画像を表示するスペースのHTML要素
  4. ブラウザ確認を行う
    1. ブラウザをリロードする
    2. プレビュー画像が表示されることを確認する
      Image from Gyazo

手順5(画像の再選択機能を追加する)

  1. 画像を選択し直したら、古いプレビューは削除されるようにする
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      console.log("preview.jsが読み込まれました");
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        // 古いプレビューが存在する場合は削除
        const alreadyPreview = document.querySelector('.preview');
        if (alreadyPreview) {
          alreadyPreview.remove();
        };
    
        console.log("input要素で値の変化が起きました");
        // 画像を取得
        console.log(e.target.files[0]);
        // 取得した画像ファイルの情報を定義
        const file = e.target.files[0];
        // 取得した画像情報のURLを生成
        const blob = window.URL.createObjectURL(file);
        console.log(blob);
    
        // 画像を表示するためのdiv要素を生成
        const previewWrapper = document.createElement('div');
        previewWrapper.setAttribute('class', 'preview');
    
        // 表示する画像を生成
        const previewImage = document.createElement('img');
        previewImage.setAttribute('class', 'preview-image');
        // 画像URLをimg要素のsrc属性に設定
        previewImage.setAttribute('src', blob);
    
        // 生成したHTMLの要素をブラウザに表示させる
        previewWrapper.appendChild(previewImage);
        previewList.appendChild(previewWrapper);
      });
    });
    
  2. ブラウザ確認を行う
    1. ブラウザをリロードする
    2. 画像選択し直した際も、プレビュー表示が1枚のみであることを確認する
      Image from Gyazo

手順6(複数枚投稿機能の実装)

手順6-1(DBリセット)

  1. 複数枚投稿機能を実装すると、以前のデータが原因でエラーとなってしまう。エラー回避のためにデータベースをリセットする
    % rails db:migrate:reset
    

手順6-2(サーバーサイドを修正)

  1. アソシエーションを設定する
    post.rb
    class Post < ApplicationRecord
      # has_one_attached :image
      has_many_attached :images # 複数枚のファイルを紐付け 
      validates :text, presence: true
      # validates :image, presence: true
      validates :images, presence: true
    end
    
  2. データ送信方法を複数枚用にするため、app/views/posts/_form.html.erbのフォームを編集する
    1. 現状、「ファイルを選択」ボタンのname属性は、post[image]という形になっている。これだと1枚のみ送信可能
      Image from Gyazo
    2. post[images][]に変更することで、複数同じname属性があった場合に、それぞれが配列の形でパラメーターとして送信される
      []を付けなかった場合、複数同じname属性があったとしても、最後の1つしか送られない
    3. data属性を追記する
      ※画像削除等を行う際に、何番目の画像が操作されたのかを判断するために使用する
      <%= form_with model: @post, id: 'new_post', local: true do |f| %>
      <%= render 'shared/error_messages', model: f.object %>
        <div class="message-field">
          <%= f.text_field :text, placeholder: 'type a message' %>
        </div>
        <div class="image-field">
          <div id="previews"></div><!-- 追加 -->
          <div class="click-upload">
            <%#= f.file_field :image %><!-- 削除 -->
            <%= f.file_field :images, name: 'post[images][]', data: {index: 0} %><!-- 複数ファイル送信可能 -->
          </div>
        </div>
        <div class="submit-btn">
          <%= f.submit '送信' %>
        </div>
      <% end %>
      
  3. ブラウザの検証ツールを開き、「ファイルを選択」ボタンのhtml構造が下記のようになっていることを確認する
    Image from Gyazo
  4. ストロングパラメーターを編集する
    posts_controller.rb
    # 省略
    
    private
    def post_params
      # params.require(:post).permit(:text, :image)
      params.require(:post).permit(:text, {images: []}) # 複数ファイル送信可能
    end
    
    # 省略
    
    ※permitの中でも、images: []の記述は必ず最後にする。最後以外だとエラー発生
  5. 画像表示を複数枚投稿機能に対応させる
    1. app/views/posts/index.html.erbを編集し、トップページは最初の画像のみを表示するように設定する
      <h3>トップページ</h3>
      <h3><%= link_to '新規投稿', new_post_path%></h3>
      <% @posts.each do |post| %>
        <div class="posted-content">
          <%#= image_tag post.image %>
          <%= image_tag post.images[0] %><!-- 最初の画像のみを表示 --><br>
          <%= post.text%><br>
          <%= link_to '詳細', post_path(post.id)%>
        </div>
      <% end %>
      
    2. app/views/posts/show.html.erbを編集し、詳細ページは画像の表示を2段構造にし、1段目には1枚目の画像を、2段目には2枚目以降の画像が表示されるように設定する
      <h3>詳細ページ</h3>
      <div class="posted-content">
        <%#= image_tag @post.image %><!--<br>--><!-- 削除 -->
        <!-- 複数画像を表示 -->
        <%= image_tag @post.images[0], class: "main-image" %><br>
        <div class="other-images">
          <% @post.images[1..-1].each do |image| %>
            <div class="other-image">
              <%= image_tag image %>
            </div>
          <%end%>
        </div>
        <!-- 複数画像を表示 -->
        <%= @post.text%><br>
        <%= link_to '編集', edit_post_path(@post.id)%>
      </div>
      
    3. CSSを修正する
      image.css
      img {
        height: 100px;
        width: 100px;
        /* 縦横比を保ったまま、画像を縦横100pxのサイズに収めるプロパティです */
        object-fit: contain;
      }
      
      /* 複数枚投稿機能の実装により、追記 */
      .other-images {
        display: flex;
      }
      
      .other-image {
        margin-right: 10px;
      }
      
      .image-delete-button {
        text-align: center;
        border: solid 1px;
        cursor: pointer;
      }
      
      .main-image {
        height: 200px;
        width: 200px;
      }
      

手順6-3(2枚目以降を選択できるようにする)

  1. 発火イベントの対象を編集する
    preview.js
    // 省略
    
    // input要素を取得
    //const fileField = document.querySelector('input[type="file"][name="post[image]"]');
    const fileField = document.querySelector('input[type="file"][name="post[images][]"]');
    
    // 省略
    
  2. 2枚目用のファイル選択ボタンを表示させる
    画像1枚につき1つのfile_field(「ファイルを選択」ボタン)が必要なため、1枚目のプレビュー画像表示が終わったら2枚目用のfile_fieldが表示されるようにする
    preview.js
    // 省略
    
    // 生成したHTMLの要素をブラウザに表示させる
    previewWrapper.appendChild(previewImage);
    previewList.appendChild(previewWrapper);
    
    // 2枚目用のfile_fieldを作成
    const newFileField = document.createElement('input');
    newFileField.setAttribute('type', 'file');
    newFileField.setAttribute('name', 'post[images][]');
    
    // 生成したfile_fieldを表示
    const fileFieldsArea = document.querySelector('.click-upload');
    fileFieldsArea.appendChild(newFileField);
    
    // 省略
    
  3. ブラウザで確認すると、1枚目を選択した後に2枚目用のfile_fieldが表示される
    Image from Gyazo
  4. プレビュー画像と追加されたファイル選択ボタンに番号をつける
    file_fieldとプレビュー画像それぞれに番号を振り、どのfile_fieldにどのプレビュー画像が紐づいているかを管理する
    そうすることで、特定の画像を削除した際などに、セットとなるfile_fieldとプレビュー画像を一緒に操作することができる
    1. 現在は、最初から表示されているfile_fieldのみ、data属性による番号が付与される設定になっている
    2. file_fieldで値の変化が起きた際に、file_fieldに付与されている番号(data-index)を取得する
      preview.js
      // 省略
      
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        // data-index(何番目を操作しているか)を取得
        const dataIndex = e.target.getAttribute('data-index');
        console.log( dataIndex);
        
        // 古いプレビューが存在する場合は削除
        const alreadyPreview = document.querySelector('.preview');
        if (alreadyPreview) {
          alreadyPreview.remove();
        };
      
      // 省略
      
    3. 取得したdata-indexの値を使用して、プレビュー画像にもdata-indexを付与する
      preview.js
      // 省略
      
      // 画像を表示するためのdiv要素を生成
      const previewWrapper = document.createElement('div');
      previewWrapper.setAttribute('class', 'preview');
      // 取得したdata-indexの値を使用して、プレビュー画像にもdata-indexを付与
      previewWrapper.setAttribute('data-index', dataIndex);
      
      // 表示する画像を生成
      const previewImage = document.createElement('img');
      previewImage.setAttribute('class', 'preview-image');
      
      // 省略
      
    4. 新しく追加されるfile_fieldへ番号を付与する
      「最後のfile_fieldのdata-index +1」と計算をしたいので、data-indexを数値に変換した上で計算する
      preview.js
      // 省略
      
      // 2枚目用のfile_fieldを作成
      const newFileField = document.createElement('input');
      newFileField.setAttribute('type', 'file');
      newFileField.setAttribute('name', 'post[images][]');
      
      // 最後のfile_fieldを取得
      const lastFileField = document.querySelector('input[type="file"][name="post[images][]"]:last-child');
      // nextDataIndex = 最後のfile_fieldのdata-index + 1
      const nextDataIndex = Number(lastFileField.getAttribute('data-index')) +1;
      // +1された値を、新しく追加するfile_fieldのdata-indexの値として渡す
      newFileField.setAttribute('data-index', nextDataIndex);
      
      // 生成したfile_fieldを表示
      const fileFieldsArea = document.querySelector('.click-upload');
      fileFieldsArea.appendChild(newFileField);
      
      // 省略
      
      :last-childは擬似クラスと呼ばれるキーワードであり、'input[type="file"][name="post[images][]"]という要素の中でも最後の要素という意味

手順6-4(コードを整理)

  1. console.logを削除
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
    
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
    
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
    
      // input要素を取得
      //const fileField = document.querySelector('input[type="file"][name="post[image]"]');
      const fileField = document.querySelector('input[type="file"][name="post[images][]"]');
    
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', function(e){
        // data-index(何番目を操作しているか)を取得
        const dataIndex = e.target.getAttribute('data-index');
        
        // 古いプレビューが存在する場合は削除
        const alreadyPreview = document.querySelector('.preview');
        if (alreadyPreview) {
          alreadyPreview.remove();
        };
    
        // 取得した画像ファイルの情報を定義
        const file = e.target.files[0];
        // 取得した画像情報のURLを生成
        const blob = window.URL.createObjectURL(file);
    
        // 画像を表示するためのdiv要素を生成
        const previewWrapper = document.createElement('div');
        previewWrapper.setAttribute('class', 'preview');
        // 取得したdata-indexの値を使用して、プレビュー画像にもdata-indexを付与
        previewWrapper.setAttribute('data-index', dataIndex);
    
        // 表示する画像を生成
        const previewImage = document.createElement('img');
        previewImage.setAttribute('class', 'preview-image');
        // 画像URLをimg要素のsrc属性に設定
        previewImage.setAttribute('src', blob);
    
        // 生成したHTMLの要素をブラウザに表示させる
        previewWrapper.appendChild(previewImage);
        previewList.appendChild(previewWrapper);
    
        // 2枚目用のfile_fieldを作成
        const newFileField = document.createElement('input');
        newFileField.setAttribute('type', 'file');
        newFileField.setAttribute('name', 'post[images][]');
    
        // 最後のfile_fieldを取得
        const lastFileField = document.querySelector('input[type="file"][name="post[images][]"]:last-child');
        // nextDataIndex = 最後のfile_fieldのdata-index + 1
        const nextDataIndex = Number(lastFileField.getAttribute('data-index')) +1;
        // +1された値を、新しく追加するfile_fieldのdata-indexの値として渡す
        newFileField.setAttribute('data-index', nextDataIndex);
    
        // 生成したfile_fieldを表示
        const fileFieldsArea = document.querySelector('.click-upload');
        fileFieldsArea.appendChild(newFileField);
      });
    });
    
  2. 関数ごとに処理を切り分け、コードを見やすく整理する
    preview.js
    document.addEventListener('turbo:load', function(){
      // 新規投稿・編集ページのフォームを取得
      const postForm = document.getElementById('new_post');
      // プレビューを表示するためのスペースを取得
      const previewList = document.getElementById('previews');
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
    
      // プレビュー画像を生成・表示する関数
      const buildPreviewImage = (dataIndex, blob) =>{
        // 画像を表示するためのdiv要素を生成
        const previewWrapper = document.createElement('div');
        previewWrapper.setAttribute('class', 'preview');
        previewWrapper.setAttribute('data-index', dataIndex);
    
        // 表示する画像を生成
        const previewImage= document.createElement('img');
        previewImage.setAttribute('class', 'preview-image');
        previewImage.setAttribute('src', blob);
    
        // 生成したHTMLの要素をブラウザに表示させる
        previewWrapper.appendChild(previewImage);
        previewList.appendChild(previewWrapper);
      };
    
      // file_fieldを生成・表示する関数
      const buildNewFileField = () => {
        // 2枚目用のfile_fieldを作成
        const newFileField = document.createElement('input');
        newFileField.setAttribute('type', 'file');
        newFileField.setAttribute('name', 'post[images][]');
    
        // 最後のfile_fieldを取得
        const lastFileField = document.querySelector('input[type="file"][name="post[images][]"]:last-child');
        // nextDataIndex = 最後のfile_fieldのdata-index + 1
        const nextDataIndex = Number(lastFileField.getAttribute('data-index')) +1;
        // +1された値を、新しく追加するfile_fieldのdata-indexの値として渡す
        newFileField.setAttribute('data-index', nextDataIndex);
    
        // 生成したfile_fieldを表示
        const fileFieldsArea = document.querySelector('.click-upload');
        fileFieldsArea.appendChild(newFileField);
      };
    
      // input要素で値の変化が起きた際に呼び出される関数の中身
      const changedFileField = (e) => {
        // data-index(何番目を操作しているか)を取得
        const dataIndex = e.target.getAttribute('data-index');
    
        // 古いプレビューが存在する場合は削除
        const alreadyPreview = document.querySelector('.preview');
        if (alreadyPreview) {
          alreadyPreview.remove();
        };
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
    
        buildPreviewImage(dataIndex, blob);
        buildNewFileField();
      };
    
      // input要素を取得
      const fileField = document.querySelector('input[type="file"][name="post[images][]"]');
    
      // input要素で値の変化が起きた際に呼び出される関数
      fileField.addEventListener('change', changedFileField);
    });
    

手順6-5(2枚目以降のプレビュー画像表示を実装)

  1. 追加されたfile_fieldにイベントをセットする
    preview.js
    // 省略
    
    // +1された値を、新しく追加するfile_fieldのdata-indexの値として渡す
    newFileField.setAttribute('data-index', nextDataIndex);
    
    // 追加されたfile_fieldにchangeイベントをセット
    newFileField.addEventListener("change", changedFileField);
    
    // 生成したfile_fieldを表示
    const fileFieldsArea = document.querySelector('.click-upload');
    fileFieldsArea.appendChild(newFileField);
    
    // 省略
    
    3枚目以降用の「ファイルを選択」のボタンが、表示されるようになった
    ※プレビュー画像は新しく選択した画像と差し替えになってしまい、まだ正しい挙動にはなっていない
  2. 正しくプレビュー表示するため、既存の画像を削除する下記記述は一旦削除する
    preview.js
    // 古いプレビューが存在する場合は削除
    const alreadyPreview = document.querySelector('.preview');
    if (alreadyPreview) {
      alreadyPreview.remove();
    };
    
  3. ブラウザで、複数枚のプレビュー画像が表示されていることを確認する
    Image from Gyazo

手順6-6(画像の変更機能を実装)

  1. data-indexを活用した画像差し替えの記述を追記する
    preview.js
    // 省略
    
    // プレビューを正しく表示させるために削除
    
    const file = e.target.files[0];
    const blob = window.URL.createObjectURL(file);
    
    // data-indexを使用して、既にプレビューが表示されているかを確認する
    const alreadyPreview = document.querySelector(`.preview[data-index="${dataIndex}"]`);
    
    if (alreadyPreview) {
      // クリックしたfile_fieldのdata-indexと、同じ番号のプレビュー画像が既に表示されている場合は、画像の差し替えのみを行う
      const alreadyPreviewImage = alreadyPreview.querySelector("img");
      alreadyPreviewImage.setAttribute("src", blob);
      return null;
    };
    
    buildPreviewImage(dataIndex, blob);
    buildNewFileField();
    
    // 省略
    
  2. ブラウザにて、画像が差し替えられることを確認する
    Image from Gyazo
  3. 画像差し替え時に画像を選択しなかった場合、file_fieldの中身はなくなるがプレビュー画像だけ表示されたままになる
    Image from Gyazo
    画像再選択をキャンセルした場合に画像データが消えてしまうのは仕様なため仕方ないが、プレビュー画像が残り、あたかも画像データが残っているように見えてしまうのは避けたい
  4. 画像差し替え時に画像を選択しなかった場合、プレビュー画像を削除する記述を追記する
    preview.js
    // 省略
    
      // 生成したfile_fieldを表示
      const fileFieldsArea = document.querySelector('.click-upload');
      fileFieldsArea.appendChild(newFileField);
    };
    
    // 指定したdata-indexを持つプレビューとfile_fieldを削除する
    const deleteImage = (dataIndex) => {
      const deletePreviewImage = document.querySelector(`.preview[data-index="${dataIndex}"]`);
      deletePreviewImage.remove();
      const deleteFileField = document.querySelector(`input[type="file"][data-index="${dataIndex}"]`);
      deleteFileField.remove();
    };
    
    // input要素で値の変化が起きた際に呼び出される関数の中身
    const changedFileField = (e) => {
      // data-index(何番目を操作しているか)を取得
      const dataIndex = e.target.getAttribute('data-index');
    
      // プレビューを正しく表示させるために削除
      //// 古いプレビューが存在する場合は削除
      //const alreadyPreview = document.querySelector('.preview');
      //if (alreadyPreview) {
      //  alreadyPreview.remove();
      //};
      // プレビューを正しく表示させるために削除
    
      const file = e.target.files[0];
    
      // fileが空 = 何も選択しなかったのでプレビュー等を削除して終了する
      if (!file) {
        deleteImage(dataIndex);
        return null;
      };
    
      const blob = window.URL.createObjectURL(file);
    
    // 省略
    
  5. 画像差し替え時に画像を選択しなかった場合、プレビュー画像が削除されることを確認する
    Image from Gyazo

手順6-7(画像の削除機能を実装)

  1. 削除ボタンを設置する
    1. 削除ボタンを生成する
      preview.js
      // 省略
      
      // 表示する画像を生成
      const previewImage= document.createElement('img');
      previewImage.setAttribute('class', 'preview-image');
      previewImage.setAttribute('src', blob);
      
      // 削除ボタンを生成
      const deleteButton = document.createElement("div");
      deleteButton.setAttribute("class", "image-delete-button");
      deleteButton.innerText = "削除";
      
      // 生成したHTMLの要素をブラウザに表示させる
      previewWrapper.appendChild(previewImage);
      previewWrapper.appendChild(deleteButton); //削除ボタンを追加
      previewList.appendChild(previewWrapper);
      
      // 省略
      
    2. CSSファイルを更新する
      preview.css
      #previews {
        display: flex;
      }
      
      .preview {
        margin-right: 30px;
      }
      
      /* 削除ボタン */
      .image-delete-button {
        text-align: center;
        border: solid 1px;
        cursor: pointer;
        margin-bottom: 5px;
      }
      
    3. 削除ボタンが表示されることを確認する
      Image from Gyazo
  2. 削除機能を実装する
    preview.js
    // 省略
    
    // 削除ボタンを生成
    const deleteButton = document.createElement("div");
    deleteButton.setAttribute("class", "image-delete-button");
    deleteButton.innerText = "削除";
    
    // 削除ボタンをクリックしたらプレビューとfile_fieldを削除させる
    deleteButton.addEventListener("click", () => deleteImage(dataIndex));
    
    // 生成したHTMLの要素をブラウザに表示させる
    previewWrapper.appendChild(previewImage);
    previewWrapper.appendChild(deleteButton); //削除ボタンを追加
    previewList.appendChild(previewWrapper);
    
    // 省略
    
  3. 削除ボタンをクリックしたらプレビュー画像とfile_fieldが削除されることを確認する
    Image from Gyazo

手順6-8(投稿できる枚数を制限する)

  1. フロントサイドで制限する
    1. 画像の最大枚数をimageLimitsとして定義する
      preview.js
      // 省略
      
      // 新規投稿・編集ページのフォームがないならここで終了。「!」は論理否定演算子。
      if (!postForm) return null;
      
      // 投稿できる枚数の制限を定義
      const imageLimits = 3;
      
      // プレビュー画像を生成・表示する関数
      const buildPreviewImage = (dataIndex, blob) =>{
      
      // 省略
      
    2. 現在表示されているプレビュー画像の枚数がimageLimits未満の場合にのみ、新しいfile_fieldが追加されるようにする
      1. buildNewFileField()を削除する
        preview.js
        // 省略
        
        if (alreadyPreview) {
          // クリックしたfile_fieldのdata-indexと、同じ番号のプレビュー画像が既に表示されている場合は、画像の差し替えのみを行う
          const alreadyPreviewImage = alreadyPreview.querySelector("img");
          alreadyPreviewImage.setAttribute("src", blob);
          return null;
        };
        
        buildPreviewImage(dataIndex, blob);
        
        //投稿枚数制限のため、削除
        //buildNewFileField();
        
        // 省略
        
      2. 下記を追記する
        preview.js
        // 省略
        
        if (alreadyPreview) {
          // クリックしたfile_fieldのdata-indexと、同じ番号のプレビュー画像が既に表示されている場合は、画像の差し替えのみを行う
          const alreadyPreviewImage = alreadyPreview.querySelector("img");
          alreadyPreviewImage.setAttribute("src", blob);
          return null;
        };
        
        buildPreviewImage(dataIndex, blob);
        
        //投稿枚数制限のため、削除
        //buildNewFileField();
        
        // 画像の枚数制限に引っかからなければ、新しいfile_fieldを追加する
        const imageCount = document.querySelectorAll(".preview").length;
        if (imageCount < imageLimits) buildNewFileField();
        
        // 省略
        
    3. ブラウザで、3枚以上画像を選択できないことを確認する
      Image from Gyazo
    4. 3枚目を追加後にファイルを削除した場合、新しい「ファイル選択」ボタンが表示されないことを確認する
      Image from Gyazo
    5. 3枚目の画像が削除された場合には、file_fieldをもう一つ生成するように記述する
      preview.js
      // 省略
      
      // 指定したdata-indexを持つプレビューとfile_fieldを削除する
      const deleteImage = (dataIndex) => {
        const deletePreviewImage = document.querySelector(`.preview[data-index="${dataIndex}"]`);
        deletePreviewImage.remove();
        const deleteFileField = document.querySelector(`input[type="file"][data-index="${dataIndex}"]`);
        deleteFileField.remove();
      
        // 画像の枚数が最大のときに削除ボタンを押した場合、file_fieldを1つ追加する
        const imageCount = document.querySelectorAll(".preview").length;
        if (imageCount == imageLimits - 1) buildNewFileField();
      };
      
      // 省略
      
    6. 3枚目を追加後にファイルを削除した場合、新しい「ファイル選択」ボタンが表示されることを確認する
      Image from Gyazo
  2. サーバーサイドで制限する
    1. モデル側でもバリデーションを設定し、3枚以上の画像投稿は弾くように制限をかける
      post.rb
      validates :images, length: { minimum: 1, maximum: 3, message: "は1枚以上3枚以下にしてください" } # 投稿枚数制限
      
    2. バリデーションが設定されていることを確認する
      Image from Gyazo
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?