📸

Laravelで確認画面+画像アップロードUI実装

2023/12/23に公開

はじめに

こちらの記事はLaravel Advent Calendar 2023の23日目の記事です。

株式会社DELTAでフロントエンドエンジニアをしている るなこです。

昨年入社してから、フロントエンドだけでなくバックエンドにもじわじわ染み出していきたい…と思っていたところ、ついにLaravelに触れることになりました。

今回は、Laravelの開発で画像アップロードUIを実装したときの検討過程を書いています!

同じような条件で実装しようとしている方の参考になれば幸いです。

今回の要件

Laravelで構築された建築資材の注文サイトに追加実装する形での開発でした。
元々管理画面から取り扱う会社情報を登録していて、フォームに登録できる情報を増やしたいという要望です。
増やす内容は企業の説明文など他にもありましたが、今回は特に実装が大変だったロゴ画像のアップロードについて取り上げます。

前提

  • 登録のフローは、入力画面 → 確認画面 → 登録
  • フォームにはロゴ画像のプレビューを表示したい。(何を選択したか確認できるようにしたいため)
  • ロゴ画像を選択すると、プレビューに反映される(同上)
  • ロゴ画像が登録されていない場合、「no image」という画像を表示する。
  • ロゴ画像を保存する時は、会社情報のid名をファイル名にする。
  • 既に登録されているロゴ画像、入力画面で選択したロゴ画像を削除して、新しい画像を選択できるようにする。
  • ロゴ画像を保存する際、会社情報のレコードにはストレージにある画像へのパスが格納される

これらを踏まえて進めます。(実際には実装中にプレビューを追加していて、行きつ戻りつしていた時間がありました)

ライブラリを使うか、自分で実装するか

結論として、自力で実装する道を選びました。画像のアップロード周りをリッチなUIで表現してくれるjQueryライブラリや、Laravelでのストレージ周りの設定をよしなにしてくれるPHPのライブラリなどがありましたが、ライブラリによって欲しい機能が一長一短だったこと、既存のコードにうまくライブラリを組み込む手間を考えると、自力で実装する工数とほぼ同じくらいになりそうだったのが理由です。

実装する上での課題

選択したロゴ画像のデータを、どうやって確認画面まで保持するか

選択した画像をプレビュー表示する機能はJavaScriptのFile APIを使えば簡単にできそうです。このままDB画像をストレージに保存するなら簡単ですが、以下のような問題が考えられます。

  • 確認画面を見て「やっぱり変更しよう」と思った時、ストレージにどんどん使わない画像が溜まってしまう
  • そのままor適当なファイル名で保存したとして、万が一別に登録中の画像ファイルと名前が被ったら上書きしてしまう

これに対して2つの方法を考えました。

  • File APIで取得した画像データ(base64形式)をブラウザのローカルストレージなどに保存し、確認画面でもプレビューエリアに読み込ませる
  • ストレージに確認画面用の一時フォルダを作成し、確認画面では一時フォルダから画像を読み込む。登録時はリネームしてロゴ用フォルダに移動

前者の方式は無駄に画像を保存しなくて済みますが、大きいサイズの画像をbase64形式で保存するとデータが巨大になってしまう点、また、会社情報が新規登録の場合にidが付与されていないので本フォルダに適当な名前で保存する必要がある(上書きリスクが変わらない)点が解決できません。このため、後者の一時フォルダ式を選びました。

結論

こんな感じになりました。

app_admin_thumbnail.js
document.addEventListener("DOMContentLoaded", function() {
    // jQuery.event.props.push('dataTransfer'); の代替
    Event.prototype.dataTransfer = null;

    let inputFile = document.getElementById("inputFile");
    let thumbnail = document.getElementById("inputFile_preview");
    let registered = document.getElementById("registered_preview");
    let deleteBtn = document.getElementById("deleteBtn");

    const flg = registered?.src.split('/').slice(-1)[0] === 'no_image.png' ? 'noImage' : 'original';
    const state = {notSelected: 'notSelected', selected: 'selected'};
    let inputFileState = state.notSelected;

    inputFile?.addEventListener("change", function() {
        const file = this.files[0];
        viewFileContent(file);
        inputFileState = state.selected;

        if(flg === 'noImage'){
            deleteBtn.style.display = 'block';
            deleteBtn.disabled = false;
        }
    });

    deleteBtn?.addEventListener("click", function() {
        if(flg === 'original' && inputFileState === 'notSelected') {
            const uri = new URL(window.location.href);
            let deleted = document.querySelector("input[name='deleted']");
            let fileName = document.querySelector("input[name='fileName']");

            registered.src = uri.origin + '/storage/img/maker/no_image.png';
            deleteBtn.style.display = 'none';
            deleteBtn.disabled = true;
            fileName.value = 'no_image.png';
            deleted.value = 'true';
        }
        thumbnail.style.display = 'none';
        registered.style.display = 'block';
        if(inputFile !== null){
            inputFile.value = '';
            inputFileState = state.notSelected;
        }
    });

    function viewFileContent(file) {
        if (!window.FileReader) {
            textViewer.textContent = "File読み込みをサポートしていません。";
            return;
        }
        if (file.type.match(/image/)) {
            let fr = new FileReader();
            fr.onload = function(event) {
                thumbnail.src = event.target.result;
                thumbnail.style.display = 'block';
                registered.style.display = 'none';
            };
            fr.readAsDataURL(file);
        }
    }
});
new.blade.php
// ~中略~
@section('js')
  <script src="{{ mix('js/app_admin_thumbnail.js') }}"></script>
@endsection

@section('content')
  <div class="row">
    <div class="col-md-12">
      <!-- TABLE: LATEST ORDERS -->
      <div class="card">
        <div class="card-body">
          {{ Form::open([
            'method' => 'POST',
            'route' => old('confirming', 'false') === 'false' ? 'admin.maker.confirm' : 'admin.maker.save',
            'class' => 'form-horizontal',
            'files' => true,
          ]) }}
          {{ Form::hidden('confirming', old('confirming', 'false')) }}
          {{ Form::hidden('func', 'new') }}
          @include('admin.element.maker_form')
          @include('admin.element.register_confirm_button')
          {{ Form::close() }}
        </div><!-- .card-body -->
        <div class="card-footer">
        </div><!-- .card-footer -->
      </div><!-- .card -->
    </div><!-- .col -->
    <div class="col-md-12 mb-3">
      <a href="{{ route('admin.maker.list') }}" class="btn btn-default">戻る</a>
    </div><!-- .col -->
  </div><!-- .row -->
@endsection
edit.blade.php
// ~中略~
@section('js')
  <script src="{{ mix('js/app_admin_thumbnail.js') }}"></script>
@endsection

@section('content')
  <div class="row">
    <div class="col-md-12">
      <!-- TABLE: LATEST ORDERS -->
      <div class="card">
        <div class="card-body">
          {{ Form::open([
            'method' => 'POST',
            'route' => old('confirming', 'false') === 'false' ? 'admin.maker.confirm' : 'admin.maker.update',
            'class' => 'form-horizontal',
            'files' => true,
          ]) }}
          {{ Form::hidden('confirming', old('confirming', 'false')) }}
          {{ Form::hidden('func', 'edit') }}
          {{ Form::hidden('id', $maker->id) }}
          @include('admin.element.maker_form')
          @include('admin.element.register_confirm_button')
          {{ Form::close() }}
        </div><!-- .card-body -->
        <div class="card-footer">
        </div><!-- .card-footer -->
      </div><!-- .card -->
    </div><!-- .col -->
    <div class="col-md-12 mb-3">
      <a href="{{ route('admin.maker.list') }}" class="btn btn-default">戻る</a>
    </div><!-- .col -->
  </div><!-- .row -->
@endsection

element/maker_form.blade.php
<h5 style="font-weight: bold">基本情報</h5>

//~中略~

<div class="form-group row">
  {{ Form::label('logo', 'ロゴ画像', [
    'class' => ['col-sm-2', 'col-form-label'],
  ]) }}

  <div class="col-sm-8">
    <div class="input-group mb-3">
      @if(old('confirming', 'false') === 'false')
        @php( $class = ['form-control'] )
        @if ($errors->has('logo'))
          @php( $class[] = 'is-invalid' )
        @endif
        <div class="custom-file">
          {{ Form::file('logo', [
            'class' => $class,
            'id' => 'inputFile',
          ]) }}
        </div>
        {{ Form::hidden('fileName', old('fileName', $maker->fileName ?? 'no_image.png')) }}
        {{ Form::hidden('deleted', old('deleted', 'false')) }}
        @else
        {{ Form::hidden('originalName', old('originalName')) }}
        {{ Form::hidden('fileName', old('fileName')) }}
        {{ Form::hidden('deleted', old('deleted')) }}
        <p class="form-control-plaintext">{{ old('originalName') ?? 'no_image.png' }}</p>
      @endif
    </div>
    <div id="preview">
      @if(old('confirming', 'false') === 'false')
        @if ($maker ?? '')
            <img src="{{ asset('storage/img/maker/' . $maker->fileName) }}" alt="" id="registered_preview">
        @else
            <img src="{{ asset('storage/img/maker/no_image.png') }}" alt="" id="registered_preview">
        @endif
            <img src="" alt="" id="inputFile_preview" style="display: none;">
      @else
        @if (old('deleted') === 'true')
          <img src="{{ asset('storage/img/maker/no_image.png') }}" alt="" id="registered_preview">
        @else
          @if (($maker->fileName ?? 'no_image.png') !== old('fileName'))
            <img src="{{ asset('storage/img/temp/' . old('fileName')) }}" alt="" id="inputFile_preview">
          @else
            <img src="{{ asset('storage/img/maker/' . ($maker->fileName ?? 'no_image.png')) }}" alt="" id="registered_preview">
          @endif
        @endif
      @endif

      @if(old('confirming', 'false') === 'false')
      @php( $class = ['btn-delete'] )
      @if (($maker->fileName ?? 'no_image.png') === 'no_image.png')
        @php( $class[] = 'hidden' )
      @endif
        {{ Form::button('✕', [
          'type' => 'button',
          'name' => 'action',
          'value' => 'deletePreview',
          'class' => $class,
          'id' => 'deleteBtn'
        ]) }}
      @endif
    </div>
    @if ($errors->has('logo'))
      <span class="text-danger"><strong>{{ $errors->first('logo') }}</strong></span>
    @endif
  </div>
</div>

プレビュー画像の表示

JavaScriptでinputFileのchangeイベントを検知して、プレビュー用のimg要素のsrcに読み取ったデータを入れています。

確認画面のロゴ画像表示

入力画面から確認画面に遷移する際、コントローラーで適当なファイル名を生成して一時フォルダに保存します。withInputメソッドで一時的なファイル名をfileNameとして渡しているので、old('fileName'))でロゴ画像を呼び出せます。

*本当は確認画面から入力画面に戻った時 選んだ画像を保持するようにしたかったのですが、JavaScriptでinput type='file'の選択操作は行えないため、リセットされる仕様になっています。もっとよい方法があればご指摘ください!!

削除機能

変数flgでデフォルト画像かそうでないかを判定し、変数inputFileStateでロゴ画像が選択されているかどうかを保持しています。

flgがデフォルト画像ではなく、inputFileStateがnotSelectedの場合のみ
元々なにかロゴ画像が登録されている状態と思われるので、プレビュー画像をデフォルト画像に切り替え、削除ボタンを非表示にします。

その後
登録済画像のプレビューエリアを削除し、選択された画像用のプレビューエリアをdisplay: block;に設定し直します。input type="file"で何かが選択されている場合、valueを空にしてinputFileStateもnotSelectedを設定します。

まとめ

改めてまとめると、今回はこのような形になりました。

  • JavaScriptで画像プレビュー
  • ストレージに一時フォルダを作って確認画面で呼び出し、登録時にリネーム・本フォルダへ移動
  • デフォルト画像flg、input type="file"のステータスで画像削除管理

viewファイルの中に条件分岐が非常に多くなってしまったため、最初に条件分岐の洗い出しをしてもう少しファイルの切り分けを意識できたら良かったかもと反省しています。。

また、確認画面から戻ると選択画像が消えているのをできれば直したいです。

とはいえ、操作に伴って表示が変化すると「操作した感」が生まれて、ページのリッチさが少し増したと思います。

もっと簡単に、リッチに実装できる方法を探すぞ~~(それってライブラリなのでは?)~~

We're Hiring!

最後までお読みいただきありがとうございます。
弊社では一緒に働いてくれる仲間を募集中です!!!!
まずはカジュアル面談からお話しましょう!

Pitta:弊社CTOの丹がベットしたい技術を語りたがってます!
Google Form:もちろんこちらからでもOK!

来てねー!

DELTAテックブログ

Discussion