3
1

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 7 × React 投稿機能 + 画像プレビュー機能の実装

Posted at

この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。

0.前提条件

X(旧Twitter)のクローンサイトの制作過程で投稿機能を実装した内容をご紹介したいと思います。

フロントエンド:React(JavaScript) 19.1.0
バックエンド:Ruby on Rails(Ruby) 7.0.0 APIモード
インフラ:Docker
PC;Mac Book Air M2チップ

ちなみに今回の記事は前回投稿した下の記事の続編といいますか、機能を完成させた内容となります。
X(旧Twitter)クローン 投稿機能の実装と躓き

1.実装概要

  • フロントエンド側で外観を整える
  • 投稿機能は本家と同じようにページのメインパーツ(中央)とサイドバーのボタンから実行できる
  • サイドバーの投稿機能はモーダルで実装
  • 画像を選択した時は画像のプレビューを表示する
  • バリデーションなどのエラーをトーストで表示させる

下の画像は実際の画面構成です。

投稿機能の画面構成.png

2.実装内容

先ずは外観を整えつつ、親コンポーネント側でモーダルの表示に関わるstate変数・関数を定義します。

MainPage.jsx
import React, { useState } from "react";
import styled from "styled-components";
import { SideBar } from "../organisms/SideBar";
import { TweetView } from "../organisms/TweetView";
import { SearchBar } from "../organisms/SearchBar";

const MainSpace = styled.div`
  background-color: black;
  display: flex;
  width: 100%;
  height: 100vh;
`;

export const MainPages = () => {
  // モーダルの表示に関するstate変数
  const [showPostModal, setShowPostModal] = useState(false);
  // モーダルを表示に切り替える
  const openPostModalHandler = () => setShowPostModal(true);
  // モーダルを非表示に切り替える
  const closePostModalHandler = () => setShowPostModal(false);

  return (
    <MainSpace>
      <SideBar openPostModalHandler={openPostModalHandler} />
      <TweetView
        showPostModal={showPostModal}
        closePostModalHandler={closePostModalHandler}
      />
      <SearchBar />
    </MainSpace>
  );
};

画面構成としては本家に倣って左からSidBar, TweetView, SearchBarとなります。
SiderBarに設置する投稿ボタンを押した場合、モーダルを出しますので管理する機能をuseStateで実装します。
showPostModalの値でモーダルの表示を切り替えます。
SideBarにはモーダルを表示するための処理を、TweetViewにはstate変数とモーダルを閉じるための処理をpropsとして渡しています。

SideBar.jsx
import React from "react";
import styled from "styled-components";
import { HomeIcon } from "../atoms/HomeIcon";
import { NotificationIcon } from "../atoms/NotificationIcon";
import { MessageIcon } from "../atoms/MessageIcon";
import { BookmarkIcon } from "../atoms/BookmarkIcon";
import { ProfileIcon } from "../atoms/ProfileIcon";
import { XHomeIcon } from "../atoms/XHomeIcon";
import { SignOutIcon } from "../atoms/SignOutIcon";
import { RaisePostModalIcon } from "../atoms/RaisePostModalIcon";

const SideSpace = styled.div`
  background-color: black;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  width: 30%;
  border-right: solid 1px #3b3b3b;
`;

const SidePart = styled.div`
  width: 45%;
`;

export const SideBar = ({ openPostModalHandler }) => {
  return (
    <SideSpace>
      <SidePart>
        <XHomeIcon />
        <HomeIcon />
        <NotificationIcon />
        <MessageIcon />
        <BookmarkIcon />
        <ProfileIcon />
        <RaisePostModalIcon
          width="200px"
          openPostModalHandler={openPostModalHandler}
        />
        <SignOutIcon />
      </SidePart>
    </SideSpace>
  );
};

SideBarコンポーネントではMainPageコンポーネントからpropsで受け取った処理をモーダルを表示させるボタンアイコン(RaisePostModalIcon)に渡しています。

RaisePostModalIcon.jsx
import React, { useState } from "react";
import styled from "styled-components";

const Button = styled.button`
  background-color: white;
  border-radius: 20px;
  text-align: center;
  height: 40px;
  color: black;
  border: none;
  font-size: 15px;
  font-weight: bold;
  margin-bottom: 20px;
  &:hover {
    background-color: #e1e3e1;
    cursor: pointer;
  }
`;

const Span = styled.span`
  padding-left: 20px;
`;

export const RaisePostModalIcon = (props) => {
  return (
    <Button style={{ width: props.width }} onClick={props.openPostModalHandler}>
      ポストする
    </Button>
  );
};

SideBarコンポーネントから受け取ったモーダル表示処理(openPostModalHandler)をonClickイベントで動くように設定します。

TweetView.jsx
import React, { useState } from "react";
import styled from "styled-components";
import { TweetTabs } from "../molecules/TweetTabs";
import { RecommendationComponent } from "./RecommendationComponent";
import { FollowComponent } from "./FollowComponent";
import { TweetInput } from "./TweetInput";
import { PostModal } from "./PostModal";

const TweetBox = styled.div`
  width: 35%;
  color: white;
`;

export const TweetView = ({ showPostModal, closePostModalHandler }) => {
  // タブの切り替えを管理するstate変数
  const [activeTab, setActiveTab] = useState("recommendation");

  // 引数のtabにrecommendation、もしくはfollowを入れることでstate関数によりタブを切り替える
  const handleTabClick = (tab) => {
    setActiveTab(tab);
  };

  return (
    <TweetBox>
      <TweetTabs activeTab={activeTab} onTabClick={handleTabClick} />
      <TweetInput />
      {activeTab === "recommendation" && <RecommendationComponent />}
      {activeTab === "follow" && <FollowComponent />}
      <PostModal show={showPostModal} close={closePostModalHandler}></PostModal>
    </TweetBox>
  );
};

続いて中央のTweetViewコンポーネントの実装です。
MainPageコンポーネントからpropsとして受け取った変数と処理を実際に表示するモーダル(PostModal)に渡します。

ここではstate変数を使ってタブの切り替えを実装していますが、既に他の記事で紹介しておりますので、詳細を割愛させていただきます。下の記事で解説しております。
Reactでタブ切り替えを実装

PostModal.jsx
import React, { useEffect, useRef, useState } from "react";
import styled from "styled-components";
import { PostIcon } from "../atoms/PostIcon";
import Animated_ryoma from "../../assets/Animated_ryoma.jpeg";
import { SeparateLine } from "../atoms/SeparateLine";
import ImageMode from "../../assets/image_mode.png";
import { handleTweet } from "../../utils/HandleTweet";
import { HandlePreview } from "../../utils/HandlePreview";

const Modal = styled.div`
  position: fixed;
  z-index: 1001;
  top: 20%;
  left: 50%;
  transform: translate(-50%, -50%);
  background-color: black;
  padding: 2rem;
  border-radius: 15px;
  min-width: 500px;
  height: auto + calc + ${(props) => props.$attachedimageheight || 0}px;
  display: flex;
  flex-flow: column;
  padding-top: 10px;
`;

const Overlay = styled.div`
  position: fixed;
  z-index: 1000;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(91, 112, 131, 0.4);
`;

const TweetModalBox = styled.div`
  width: 80%
  display: flex;
  justify-content: space-between;
  align-items: center;
`;

const Image = styled.img`
  border-radius: 50%;
`;

const InputPlace = styled.input`
  background-color: black;
  width: calc(100% - 60px);
  margin-left: 10px;
  color: white;
  font-size: 20px;
  border: none;
  &:focus {
    outline: none;
  }
`;

const PostBox = styled.div`
  display: flex;
  align-items: center;
`;

const ImageUploadSet = styled.div`
  position: relative;
  width: 100%;
  height: 100%;
`;

const ImageButton = styled.button`
  height: 24px;
  width: 24px;
  background-color: white;
  border: none;
  cursor: pointer;
  position: absolute;
  top: 50%;
  left: 20%;
  -ms-transform: translate(-50%, -50%);
  -webkit-transform: translate(-50%, -50%);
  transform: translate(-50%, -50%);
  opacity: 0;
  z-index: 1;
`;

const DummyImage = styled.img`
  position: absolute;
  top: 50%;
  left: 20%;
  -ms-transform: translate(-50%, -50%);
  -webkit-transform: translate(-50%, -50%);
  transform: translate(-50%, -50%);
  z-index: 0;
`;

const PreviewBox = styled.div`
  display: flex;
`;

export const PostModal = ({ show, close }) => {
  const [tweetContent, setTweetContent] = useState("");
  // ツイートに関連づけるための画像を扱う
  const [tweetImage, setTweetImage] = useState([]);
  // 投稿欄(モーダル)に表示させるプレビュー用の画像を扱う
  const [imagePreview, setImagePreview] = useState([]);
  // プレビュー画像が表示される時に投稿欄コンポーネントの高さを調整するために使用
  const [attachedImageHeight, setAttachedImageHeight] = useState(0);
  // 画像選択ボタンから参照させるために使用
  const fileInputRef = useRef(null);

  // 画像が選択された時点でフォームの高さを調整したいので、画像の高さを取得してCSSに反映
  const onImageLoad = (e) => {
    setAttachedImageHeight(e.target.height);
  };

  // 投稿ボタンが押された時の処理。画像処理後に投稿処理となる。
  const handlePostClick = async () => {
    handleTweet({
      tweetContent,
      tweetImage,
      setTweetContent,
      setTweetImage,
      setImagePreview,
      close,
    });
  };

  // 投稿欄の入力文字を保管
  const handleTweetChange = (e) => {
    setTweetContent(e.target.value);
  };

  // inputで選択された画像ファイルをプレビュー画像として表示する
  const handleChangePostImage = (e) => {
    // 選択された画像ファイルが入る。?でe.targetが存在しない場合にエラーを防ぐ
    const uploadedFiles = e.target?.files;
    HandlePreview({
      uploadedFiles,
      tweetImage,
      imagePreview,
      setImagePreview,
      setTweetImage,
      e,
    });
  };

  const handleImageClick = (index) => {
    // prevにはimagePreviewのstate変数が入る。_は慣習的に使わない引数に対して使用する
    setImagePreview((prev) => prev.filter((_, idx) => idx !== index));
  };

  useEffect(() => {
    // showがtrue且つ、押下されたキーがEscapeボタンの場合に実行される
    const onKeyDownEsc = (event) => {
      if (show && event.key === "Escape") {
        event.preventDefault();
        close();
      }
    };
    // window全体にイベントリスナーを追加
    window.addEventListener("keydown", onKeyDownEsc);
    // useEffectの再実行やアンマウント時に前回のイベントリスナーを削除
    return () => window.removeEventListener("keydown", onKeyDownEsc);
  }, [show, close]);

  if (!show) return <></>;
  return (
    <>
      <Overlay onClick={close}></Overlay>
      <Modal $attachedimageheight={attachedImageHeight}>
        <TweetModalBox>
          <Image
            src={Animated_ryoma}
            alt="animated_ryoma"
            width="40px"
            height="40px;"
          />
          <InputPlace
            type="text"
            placeholder="いまどうしてる?"
            value={tweetContent}
            onChange={handleTweetChange}
          />
        </TweetModalBox>
        <PreviewBox>
          {!!imagePreview &&
            // imagePreviewにプレビュー画像用のデータURLが格納される
            // map関数で=> () アロー関数を使うことで要素をreturnしていることで表示できる
            imagePreview.map((preview, idx) => (
              <output key={idx}>
                <img
                  src={preview}
                  alt="画像プレビュー"
                  width="30%"
                  height="30%"
                  onLoad={onImageLoad}
                  onClick={() => handleImageClick(idx)}
                />
              </output>
            ))}
        </PreviewBox>
        <SeparateLine />
        <PostBox>
          <ImageUploadSet>
            <ImageButton
              onClick={() =>
                // inputタグを参照しており、このボタンをクリックするとinputタグをクリックしたことになる。!!は値を真偽値に変換
                !!fileInputRef.current && fileInputRef.current.click()
              }
            />
            <input
              type="file" // これによりファイル選択のウィンドウが表示される
              accept="image/*"
              hidden
              multiple
              ref={fileInputRef} // ImageButtonコンポーネントから参照されている。
              onChange={handleChangePostImage}
            />
            <DummyImage src={ImageMode} />
          </ImageUploadSet>
          <PostIcon
            width="120px"
            margin-bottom="0px"
            handlePostClick={handlePostClick}
          />
        </PostBox>
      </Modal>
    </>
  );
};
  • state変数について
    TweetViewコンポーネントから受け取ったstate変数のshow(showPostModal)を使って値がfalseであれば、空タグ <>>を返却し、tureであれば必要なモーダルの内容を表示させる実装です。
    投稿内容についてはuseStateを使ってtweetContentで文字を、tweetImageで画像を管理します。
    また、投稿のモーダルに選択した画像をプレビュー画像として表示させるために、imagePreviewのstate変数を別途使用します。
    attachedImageHeightのstate変数は画像が選択された時にプレビュー画像を表示するのに投稿入力のサイズ(高さ)を調整します(画像の高さ分を足す)ために高さを保存します。

  • 共通の処理(handleTweet, HandlePreview)について
    投稿機能とプレビュー機能は他のコンポーネントと共通の実装なので、関数として書き出しておりまとめて後述します。

  • プレビュー画像の削除
    handleImageClickはプレビュー画像をクリックした際に削除する処理です。onClickイベントで引数としてindexを受け取り、関数型アップデートでの処理を利用して直前のimagePreview変数をprevとして取得して、filter関数を通します。imagePreview変数のそれぞれから値とindexを取得して、引数として受け取ったindexと異なるものだけを残し、indexが同じものを省くことで選択した画像を処理になります。

  • Escapeキーでモーダルを閉じる処理
    useEffectを使ってモーダルを閉じる処理を実装しています。
    window全体にキーを押下した時のイベントリスナーとしてonKeyDownEscを設定。
    クリーンアップ関数として古いイベントリスナーを削除する処理を設定。クリーンアップ関数は依存配列(show, close)が変更されるか、対象のコンポーネントが非表示(アンマウント)される場合に実行されます。それまでは処理を待機させておくイメージです。
    登録した処理ではpropsで受け取ったshow変数がtrue、且つ押下されたキーがEscapteである場合に発動する処理で、Escapeキーのデフォルト機能の再読み込みを防止しつつ、propsで受け取ったclose関数を実行(モーダルを閉じる)する処理となります。

  • コンポーネントについて
    OverlayはMainPageとモーダルの間に置くDOMで、z-indexで階層を分けています。
    PreviewBoxではプレビュー画像を表示するスペースを設定しています。imagePreviewのstate変数内に画像データが保管されている場合、map関数で一つずつ展開して表示させます。
    PostBox内のImageUploadSetではCSSで重ねたImageButton, input, DummyImageをクリックした時に画像の選択ができるように設定しています。useRefを使ってImageButtonをクリックしたらinputをクリックしたことになる設定にしています。

HandleTweet.jsx
import React from "react";
import { axiosInstance } from "./HandleAxios";
import { HandleError } from "./HandleError";
import toast from "react-hot-toast";

// 投稿ボタンが押された時の処理。画像処理後に投稿処理となる。
export const handleTweet = async ({
  tweetContent,
  tweetImage,
  setTweetContent,
  setTweetImage,
  setImagePreview,
  e,
  ...rest // closeはモーダル側でのみ使用するため、restという名前のオブジェクトとして受け取る
}) => {
  try {
    console.log(tweetContent);
    // 投稿欄が空でなければ画像のidと投稿文をAPI側へ送信する。空の場合はユーザーに知らせて終了
    if (tweetContent !== "") {
      // 画像ファイルを送る場合、FormDataに格納する必要がある
      const formData = new FormData();
      // 複数の画像に対応するためにimageIdを配列で管理する。他の関数から参照できるようにtry内で最上階で宣言
      const imageId = [];
      // !!でtweetImageを真偽値に変換
      if (!!tweetImage) {
        tweetImage.forEach((file) => {
          // 明示的にfiles[]とすることで、API側のparams[:files]を配列データとする
          formData.append("files[]", file);
        });
        // 画像ファイルをAPi側のcontrollerに送信
        if (tweetImage.length > 0) {
          const imageResponse = await axiosInstance.post("/images", formData);
          console.log(imageResponse.data);
          // API側から受け取った画像ファイルのblobsのハッシュデータからidを取得して画像idとして追加する
          for (let res of imageResponse.data.blobs) {
            imageId.push(res.id);
          }
        }
      }
      console.log(...formData.entries());
      console.log(tweetImage);
      const response = await axiosInstance.post("/tweets", {
        tweet: { content: tweetContent, image_ids: imageId },
      });
      console.log(response.data);
      // 投稿が成功したら投稿欄、state変数の画像とプレビュー画像を初期化する
      if (response.status === 200) {
        console.log("Tweeted successfully!");
        setTweetContent("");
        setTweetImage([]);
        setImagePreview([]);
        if (rest.close) {
          rest.close();
        }
      }
    } else {
      toast.error("空での投稿はできません。");
      return;
    }
  } catch (error) {
    HandleError(error);
  }
};
HandleAxios.jsx
import axios from "axios";
import React from "react";

export const axiosInstance = axios.create({
  timeout: 3000,
});

axiosInstance.interceptors.request.use(
  (config) => {
    // トークン情報をローカルストレージから取得しておく
    const access_token = localStorage.getItem("access-token");
    const client = localStorage.getItem("client");
    const uid = localStorage.getItem("uid");
    if (access_token && client && uid) {
      config.headers["access-token"] = access_token;
      config.headers["client"] = client;
      config.headers["uid"] = uid;
    }
    return config;
  },
  (error) => {
    // リクエストエラーが発生した場合の処理
    return Promise.reject(error);
  }
);
images_controller.rb
# frozen_string_literal: true

module Api
  module V1
    class ImagesController < ApplicationController
      def create
        files = params[:files]
        files = [files] unless files.is_a?(Array)
        blobs = files.map do |file|
          blob = ActiveStorage::Blob.create_and_upload!(io: file, filename: file.original_filename)
          { id: blob.id, url: url_for(blob) }
        end
        render json: { blobs: }
      end
    end
  end
end
tweets_controller.rb
module Api
  module V1
    class TweetsController < ApplicationController
      before_action :set_user, only: %i[create]
      before_action :authenticate_api_v1_user!, only: %i[create]

      def create
        # current_userに紐づけてtweetを生成する
        @tweet = @user.tweets.new(tweet_params)
        if @tweet.save
          # パラメーターにimage_idsが含まれている場合、アップロードされたデータであるBlobから該当のidを持つデータを取得
          # 取得した画像データをツイートに紐づける
          if params[:tweet][:image_ids]
            blobs = ActiveStorage::Blob.where(id: params[:tweet][:image_ids])
            @tweet.images.attach(blobs)
          end
          render json: { status: 'SUCCESS', message: 'Saved tweet', data: { id: @tweet.id, content: @tweet.content, images: @tweet.image_urls } }
        else
          render json: { status: 'ERROR', message: 'Tweet not saved', data: @tweet.errors.full_messages }, status: :unprocessable_entity
        end
      end

      private

      def tweet_params
        params.require(:tweet).permit(:user_id, :content)
      end

      def set_user
        # current_userを取得して変数に代入。apiモードではcurrent_api_v1_userと記述する
        @user = User.find(current_api_v1_user.id)
      end
    end
  end
end
tweet.rb
# frozen_string_literal: true

class Tweet < ApplicationRecord
  include Rails.application.routes.url_helpers

  belongs_to :user
  validates :content, presence: true, length: { in: 1..140 }
  has_many_attached :images

  # オブジェクトをJSON形式に変換する際の出力内容をカスタマイズする。通常の属性(idやname)に加えてimage_urlsメソッドの返り値も含める
  def as_json(options = {})
    super(options.merge(methods: :image_urls))
  end

  # モデルに紐づく複数の画像から各画像ファイルのアクセスURLを生成し、配列で返す。
  def image_urls
    images.map { |image| url_for(image) }
  end
end

handleTweet関数では共通処理として切り出した投稿処理を実装しています。

  • propsとしてstate変数とstate関数を受け取っておりますが、closeだけはPostModalコンポーネント側からしか受け取らないため、closeを渡さない別のコンポーネントで動作させて直接名前指定で受け取ろうとするとエラーが発生します。エラーを回避するためにcloseだけは...restというオブジェクトとして受け取るようにしています。使用する時はrest.closeといったプロパティの書き方になります。
  • if (tweetContent !== "") {
    投稿ボタンを押した時に処理が発火するようにしており、投稿内容が空文字であればtoastを使ってユーザーに知らせるようにして処理を中断(return)させます。投稿内容は文字と画像を別々のinputタグから入力します。画像を送信する手段として、今回はformDataにデータを格納して送信する処理を採用しています。formDataはBlob, File, 文字列を保管できるJavaScriptのオブジェクトで、keyとvalueの組み合わせで補完されます。tweetImage変数の中身がある場合のみ画像側の処理を動かします。配列であるtweetImage変数に対してforEach文で中身の要素に対して一つずつインスタンスとして生成したformDataへ配列として加えていきます。
  • await axiosInstance.post("/images", formData); 〜 images_controller.rb
    次にaxiosを使ってバックエンド側へ画像データを送ります。axiosInstanceはローカルストレージから取得したトークン情報をヘッダーに加える処理をinterceptorsで加えています。
    リクストボディに画像ファイルを保管したformDataを入れてHTTP通信することで、バックエンド側ではparams[:files]としてデータを取得する事ができます。また、formDataにデータを加える時に`formData.append("files[]", file)とすることでRails側にfilesが配列形式であることを認識させることができます。取得したfilesをmap関数で展開して1つずつ処理する時にActiveStorageにファイルとファイル名を渡すことでActiveStorage::Blobデータが生成されてblob変数に格納します。最終的にblobのidとファイルにアクセスするためのURLをurl_for(blob)で生成してハッシュとして返してblobs変数に代入しています。blobs変数はファイルの数だけハッシュの配列を持つことになります。バックエンド側から返ってきたblobs変数をimageResponseに格納します。imageIdに対してblobsのidを格納することで先にアップロードした画像を取得するための準備になります。
  • await axiosInstance.post("/tweets", 〜 tweets_controller.rb
    次に投稿された文字列と先に取得したimageIdをリクエストボディに入れて再度バックエンド側へ送信します。ストロングパラメーターで受け取ったデータをログインしているユーザー紐づけて投稿データを生成します。image_idsはtweetモデルのカラムには存在しないので、ストロングパラメーターには含めません。そして、送信されてきたデータにimage_idsが含まれている場合、idからActiveStorageで生成した画像ファイルを取得してblobsに代入、先に生成した投稿データと関連づけます。投稿が完了してstatus200で返ってきたら、state変数の中身を空にしてモーダルを閉じます(rest.close())。
HandlePreview.jsx
import React from "react";

export const HandlePreview = ({
  uploadedFiles,
  tweetImage,
  imagePreview,
  setImagePreview,
  setTweetImage,
  e,
}) => {
  // 何も選択されなかったら処理中断
  // ?を付けることでe.target.filesがnull/undefinedの場合はundefinedを返し、エラーを回避する
  if (uploadedFiles.length === 0) {
    return;
  }

  // ファイルが画像でなかったら処理中断
  // ?を付けることでe.target.filesがnull/undefinedの場合はundefinedを返し、エラーを回避する
  if (!uploadedFiles[0].type.match("image.*")) {
    return;
  }

  // 画像ファイルはFileListオブジェクト(配列のようなもの)なので配列データに変換する
  const filesArray = Array.from(uploadedFiles);
  const readers = filesArray.map((file) => {
    return new Promise((resolve) => {
      // FileReader1つに対して1つのファイルしか入れられない。
      const reader = new FileReader(); // ファイル内容をメモリに読み込むAPI
      reader.onload = (e) => resolve(e.target.result); // 読み込み完了時に発火し、結果をe.target.resultで取得
      reader.readAsDataURL(file); // ファイルをData URL形式で非同期読み込み。画像表示にはData URL形式が必要
    });
  });

  // readersに格納されたPromiseオブジェクトの配列を全て読み込む
  // Data URL配列をimagePreview変数に保管する
  Promise.all(readers).then((results) => {
    setImagePreview(results);
  });
  console.log(imagePreview);

  // FileListオブジェクト(配列のようなもの)をArray.fromで配列に変換する。選択した画像がe.target.filesに入る
  const files = Array.from(e.target.files);
  if (files.length > 0) {
    // スプレッド構文でフラットな配列を生成する。既存の画像に選択した画像を追加する。画像投稿の前準備
    setTweetImage([...tweetImage, ...files]);
  }
};

ここでは画像のプレビュー表示に関する処理を行います。

  • 選択したファイルをuploadedFilesに代入して、他の変数・関数と一緒にpropsとして渡しています。
    ファイルはFileListオブジェクトと呼ばれる形式で配列のようなものですが異なります。配列として扱えるようにArray.fromで変換します。次にmap関数でファイルを1つずつ取り出して、FileReaderインスタンスを生成、readAsDataURLメソッドでファイルをData URL形式で読み込みます。プレビューで画像を表示するためにはData URL形式に変換する必要があります。読み込み完了時にe.target.resultでPromise配列として値を取得してreadersに代入します。ファイルの読み込みには時間が掛かるので、非同期処理での実装となります。
  • Promise.all(readers)
    全てのPromiseオブジェクト読み込みを待つためにPromise.allを使用します。読み込みが完了したらstate関数でimagePreviewの内容を書き換えます。最後に、プレビュー画像として読み込まれた画像は投稿にも使用するので、setTweetImage関数を使って選択したファイルで更新します。
TweetInput.jsx
import React, { useEffect, useRef, useState } from "react";
import Animated_ryoma from "../../assets/Animated_ryoma.jpeg";
import styled from "styled-components";
import ImageMode from "../../assets/image_mode.png";
import { PostIcon } from "../atoms/PostIcon";
import toast, { Toaster } from "react-hot-toast";
import { handleTweet } from "../../utils/HandleTweet";
import { HandlePreview } from "../../utils/HandlePreview";

const TweetSpace = styled.div`
  border-top: solid 1px #3b3b3b;
  border-bottom: solid 1px #3b3b3b;
  width: 100%;
  // propsとして受け取ったプレビュー画像の高さを足すことで全体の高さを調整
  height: calc(175px + ${(props) => props.$attachedimageheight || 0}px);
  padding: 15px 15px 15px 15px;
`;

const PairBox = styled.div`
  display: flex;
  align-items: center;
  width: 100%;
`;

const Image = styled.img`
  border-radius: 50%;
`;

const InputPlace = styled.input`
  background-color: black;
  width: calc(100% - 30px);
  color: white;
  font-size: 20px;
  border: none;
  margin-left: 10px;
  &:focus {
    outline: none;
  }
`;

const UnderBox = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-right: 20px;
`;

const ImageUploadSet = styled.div`
  position: relative;
  width: 100%;
  height: 100%;
`;

const ImageButton = styled.button`
  height: 24px;
  width: 24px;
  background-color: white;
  border: none;
  cursor: pointer;
  position: absolute;
  top: 50%;
  left: 20%;
  -ms-transform: translate(-50%, -50%);
  -webkit-transform: translate(-50%, -50%);
  transform: translate(-50%, -50%);
  opacity: 0;
  z-index: 1;
`;

const DummyImage = styled.img`
  position: absolute;
  top: 50%;
  left: 20%;
  -ms-transform: translate(-50%, -50%);
  -webkit-transform: translate(-50%, -50%);
  transform: translate(-50%, -50%);
  z-index: 0;
`;

const PreviewBox = styled.div`
  display: flex;
`;

export const TweetInput = () => {
  const [tweetContent, setTweetContent] = useState("");
  // ツイートに関連づけるための画像を扱う
  const [tweetImage, setTweetImage] = useState([]);
  // 投稿欄に表示させるプレビュー用の画像を扱う
  const [imagePreview, setImagePreview] = useState([]);
  // プレビュー画像が表示される時に投稿欄コンポーネントの高さを調整するために使用
  const [attachedImageHeight, setAttachedImageHeight] = useState(0);
  // 画像選択ボタンから参照させるために使用
  const fileInputRef = useRef(null);

  // 画像が選択された時点でフォームの高さを調整したいので、画像の高さを取得してCSSに反映
  const onImageLoad = (e) => {
    setAttachedImageHeight(e.target.height);
  };

  const handlePostClick = (e) => {
    console.log(tweetContent);
    handleTweet({
      tweetContent,
      tweetImage,
      setTweetContent,
      setTweetImage,
      setImagePreview,
      e,
    });
  };

  // 投稿欄の入力文字を保管
  const handleTweetChange = (e) => {
    setTweetContent(e.target.value);
  };

  // inputで選択された画像ファイルをプレビュー画像として表示する
  const handleChangePostImage = (e) => {
    // 選択された画像ファイルが入る。?でe.targetが存在しない場合にエラーを防ぐ
    const uploadedFiles = e.target?.files;
    HandlePreview({
      uploadedFiles,
      tweetImage,
      imagePreview,
      setImagePreview,
      setTweetImage,
      e,
    });
  };

  const handleImageClick = (index) => {
    // prevにはimagePreviewのstate変数が入る。_は慣習的に使わない引数に対して使用する。選択した画像を削除
    setImagePreview((prev) => prev.filter((_, idx) => idx !== index));
  };

  return (
    // attachedImageHeightはプレビュー画像の高さ
    <TweetSpace $attachedimageheight={attachedImageHeight}>
      <PairBox>
        <Image
          src={Animated_ryoma}
          alt="animated_ryoma"
          width="40px"
          height="40px;"
        />
        <InputPlace
          type="text"
          placeholder="いまどうしてる?"
          value={tweetContent}
          onChange={handleTweetChange}
        />
      </PairBox>
      <PreviewBox>
        {!!imagePreview &&
          // imagePreviewにプレビュー画像用のデータURLが格納される
          // map関数で=> () アロー関数を使うことで要素をreturnしている。returnしないと表示されない
          imagePreview.map((preview, idx) => (
            <output key={idx}>
              <img
                src={preview} // Data URLが入ることにより画像が表示される
                alt="画像プレビュー"
                width="50%"
                height="50%"
                onLoad={onImageLoad}
                onClick={() => handleImageClick(idx)}
              />
            </output>
          ))}
      </PreviewBox>
      <UnderBox>
        <ImageUploadSet>
          <ImageButton
            onClick={() =>
              // inputタグを参照しており、このボタンをクリックするとinputタグをクリックしたことになる。!!は値を真偽値に変換
              !!fileInputRef.current && fileInputRef.current.click()
            }
          />
          <input
            type="file" // これによりファイル選択のウィンドウが表示される
            accept="image/*"
            hidden
            multiple
            ref={fileInputRef} // ImageButtonコンポーネントから参照されている。
            onChange={handleChangePostImage}
          />
          <DummyImage src={ImageMode} />
        </ImageUploadSet>
        <PostIcon
          width="120px"
          margin-bottom="0px"
          handlePostClick={handlePostClick}
        />
        <Toaster position="top-center" />
      </UnderBox>
    </TweetSpace>
  );
};

こちらはモーダルではなく常時表示されているinputで投稿するためのコンポーネントです。モーダルを使った時と処理の内容はほぼ同じです。

4.参考資料

React.jsでの画像アップロードからプレビュー表示
FormData オブジェクトの使用
Rails コンソールから Active Storage のファイルアップロードを実行する
インターセプター
Interceptors in React with Axios

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?