[Ruby on Rails]stimulusを用いたプレビュー画像表示機能実装(初学者向け)

私は現在、未経験からのエンジニア転職に向けてプログラミングスクールで学習をしている、いしかわと申します。

今回Webアプリケーションの個人開発で`stimulus`を用いたプレビュー画像表示機能を実装したのでアウトプットとして記事にしたいと思います。
どなたかの参考になれば幸いです。

:::note warn
プログラミング初学者なので、内容に誤り等ある可能性があります
誤りがありましたら教えてくださると幸いです
:::
**環境**
・Mac M1
・Ruby3.2.2
・Ruby on Rails 7.0.8
※importmapではなくesbuildでバンドルしています

# 期待する実装内容
入力フィールドから画像を選択した時、その画像がいずれかの場所に表示される
その画像は保存されているわけではないプレビュー画像とする

# stimulusコントローラの作成
`javascript/controllers`配下に`image_preview_controller.js`を新たに作成します
```javascript:image_preview_controller.js
// stimulusのControllerクラスをインポートすることを宣言
import { Controller } from "stimulus"

// 他のファイルからこのコントローラをインポートできるようにすることを宣言
export default class extends Controller {

    static targets = ["input", "preview"]

    previewImage() {
        const input = this.inputTarget
        const preview = this.previewTarget
        const files = input.files
        const width = this.data.get('width')

        if (files && files[0]) {
        const reader = new FileReader()
        reader.onload = (e) => {
            preview.innerHTML = `<img src="${e.target.result}" style="max-width: ${width}px;">`;
            }
        reader.readAsDataURL(files[0])
        }
    }    
}
```
## ターゲット名を定義
```javascript
static targets = ["input", "preview"]
```
`input`と`preview`をこのコントローラーが操作するDOM要素（ターゲット）として定義しています
後述しますが
`input`はプレビュー表示させたいファイルを読み込む`file_field`を
`preview`はプレビュー画像を表示させるdivタグを指定します

## previewImageメソッドの定義
```javascript
previewImage() {
```
ここからプレビュー画像を表示させる`previewImageメソッド`を作成していきます
### 
```javascript
const input = this.inputTarget
const preview = this.previewTarget
const files = input.files
const width = this.data.get('width')
```
このメソッド内で使用するために4つの変数を定義します
`input`と`preview`は先ほど書いた通りプレビュー画像を読み込む要素と、表示させる要素です。
Stimulusは`data-target属性`を使ってHTML要素とコントローラのプロパティを結びつけることができます
`files`はユーザーが選択したファイルのリストです
`width`はプレビュー画像のサイズを任意のサイズに変えることができるよう設定しています。これにより様々なビューファイルでこのstimulusコントローラを使用するのが楽になります
```javascript
if (files && files[0]) {
```
`if`による条件式を定義しています
`files`は`input.file`から取得されているので[`FileList`オブジェクト](https://developer.mozilla.org/ja/docs/Web/API/FileList)です。この`FileList`オブジェクトを参照した場合`true`を返します。
※`files`は空でも`FileList`オブジェクトは存在するので`true`となります
`files[0]`は`files`リストの最初の要素を示しています。つまり`files`がなんらかの要素を持っている場合に`true`を返します

この2つの条件が`&&(論理AND)演算子`で結ばれているので、`if`の条件は「ユーザーが少なくとも1つのファイルを選択していて、それにアクセスできる状態である」となります

```javascript
const reader = new FileReader()
```
変数`reader`を[`FileReader`オブジェクト](https://developer.mozilla.org/ja/docs/Web/API/FileReader)として定義しています
`FileReaderオブジェクト`は非同期にファイルを読み込むことができます

```javascript
reader.onload = (e) => {}
```
変数`reader`にファイル読み込みが完了した(`onload`)場合、実行される関数を記述しています
この関数に記述されている`(e)`はイベントオブジェクトであり、今回のように`FileReader`オブジェクトの`onload`イベントハンドラで`(e)`として参照される場合、以下のような情報が含まれています
```
target: イベントを発生させた`FileReader`オブジェクト自体指します
target.result: FileReader が読み込んだファイルの内容で、Base64エンコーディングされたデータURLです
type: イベントの種類を表します
timeStamp: イベントが発生した時刻をミリ秒単位で示します
```
特に、`e.target`はイベントを発生させた`FileReader`オブジェクト自体を指します。`e.target.result`には読み込んだファイルの内容が含まれており、これはBase64エンコーディングされたデータURLの形式で提供されます。このデータURLを画像の`src`属性に設定することで、ブラウザ上に画像を直接表示することが可能になります。
```javascript
preview.innerHTML = `<img src="${e.target.result}" style="max-width: ${width}px;">`;
```
`preview`のターゲット要素にHTMLを追加しています。`(e)`の`target.result`情報を抜き出すことにより、読み込んだファイルの内容を`img`タグで出力しています
また`style="max-width: ${width}px`で`width`ターゲットを用いてプレビュー画像を任意の幅に調整できるようにしています

```javascript
reader.readAsDataURL(files[0])
```
`reader`に対して[`readAsDataURL`メソッド](https://developer.mozilla.org/ja/docs/Web/API/FileReader/readAsDataURL)を使っています
[`readAsDataURL`メソッド](https://developer.mozilla.org/ja/docs/Web/API/FileReader/readAsDataURL)はfileの内容を読み込み、データURLとして返す(Base64エンコーディングされた文字列)メソッドです
ユーザーが`files`からファイルを選択した時、そのファイルは`files`配列の0番目に格納されるため`files[0]`と表すことができます
`FileReader`オブジェクトである`reader`は`files[0]`の内容を非同期に読み込み、その内容をデータURLとして保持します。
読み込みが完了すると、`FileReader` オブジェクトの `onload` イベントが発火します。その結果、読み込まれたファイルのデータは `e.target.result` を通じてアクセス可能になります。

# application.jsへimage_preview_controller.jsを追加
```javascript:application.js
import { Application } from "stimulus";
import ImagePreviewController from "./controllers/image_preview_controller";

const application = Application.start();
application.register("image-preview", ImagePreviewController);
```
esbuildを使用しているため、以上の手順でコントローラを`application.js`に追加します
# ビューファイルで使用
今回はユーザーのprofile情報を編集する時、ユーザーのアイコンを変更する場合のプレビュー機能実装を想定してビューファイルを作成しています
```html:edit.html.erb
<div>
    <% if @profile.avatar.attached? %>
        <%= image_tag(@profile.avatar_thumbnail)%>
    <% end %>
    <div data-controller="image-preview" data-image-preview-width="250">
        <%= f.file_field :avatar, data: { "image-preview-target": "input", "action": "change->image-preview#previewImage"}" %>
        <div data-image-preview-target="preview"></div>
    </div>
</div>
```
```html
<div data-controller="image-preview" data-image-preview-width="250">
```
`image_preview`コントローラを使用することを宣言し、`width`の値を250としてstimulusコントローラに渡しています
この`width`の値は自由に設定できるので、別のビューページでサイズ違いのプレビューを表示させたい時も同じコントローラを使い回すことで簡単に実装できます
```html
<%= f.file_field :avatar, data: { "image-preview-target": "input", "action": "change->image-preview#previewImage"}" %>
```
このinput要素が`input`ターゲットであることを指定しています
また`"action": "change->image-preview#previewImage"`とすることで`change`イベントが発生したとき`ImagePreview`メソッドが発火するようにしています
```html
<div data-image-preview-target="preview"></div>
```
この要素に`PreviewImage`メソッドが発火した時に生成される`<img src="${e.target.result}" style="max-width: ${width}px;">`を追加することを明示しています