figma-clone作り中の棚卸し
本記事の内容
基本的には技術の棚卸しを目的としています。
現在、下記の動画を参考にfigma-cloneを作成し、勉強しています、
今回は、途中報告としています。
そのため、環境構築などは、下記動画を参考にしてください。英語ですが、指示に従えば特に問題はないです。
フォルダ構成のおさらい
本プロジェクト「figma-clone」のディレクトリ構成は、概ね以下の通りです。
figma-clone
├─ app
│ ├─ layout.tsx
│ ├─ page.tsx
│ └─ Room.tsx
└─ components
├─ Live.tsx
├─ cursor
│ ├─ Cursor.tsx
│ ├─ CursorChat.tsx
│ └─ LiveCursors.tsx
└─ reaction
├─ FlyingReaction.tsx
├─ index.module.css
└─ ReactionButton.tsx
-
app/
ディレクトリ: Next.js(App Router) で使われる主要ファイル。 -
components/
ディレクトリ: Reactコンポーネントを機能ごとに配置している。-
cursor/
… カーソル表示やチャット機能に関連 -
reaction/
… リアクション(絵文字)などに関連
-
この時点でのできること
このシステム(記事)で実現できることは、「複数ユーザーが同じ画面を見ながら、マウスカーソルやメッセージ、リアクション(絵文字)をリアルタイムに共有し合える」アプリの仕組みを学べることです。記事を読むことで、Next.js と Liveblocks を組み合わせたリアルタイムコラボアプリの具体的な実装方法を理解できます。
全体像
現状でできる機能は以下です。
-
Next.js の App Router 構成
-
app/
ディレクトリ配下のlayout.tsx
とpage.tsx
でアプリ全体のレイアウトとページを管理し、Reactコンポーネントを自由に呼び出せる。
-
-
Liveblocks を使ったリアルタイム通信
-
Room.tsx
でLiveblocksProvider
とRoomProvider
を設定しており、全コンポーネントで「自分の状態」と「他人の状態」をリアルタイムに共有できる。 -
useMyPresence
,useOthers
,useBroadcastEvent
,useEventListener
などのフックを通じて、カーソル座標やメッセージ、イベント(リアクション)を送受信。
-
-
カーソルとチャット機能
-
Cursor.tsx
,CursorChat.tsx
,LiveCursors.tsx
で「カーソルの表示」「チャットモードの入力フォーム」「他ユーザーのカーソル描画」が実装され、ユーザー同士のやり取りを可視化できる。
-
-
絵文字リアクションとアニメーション
-
FlyingReaction.tsx
とindex.module.css
を組み合わせ、絵文字が画面上をフワッと飛んで消えるアニメーションを実装。 -
ReactionButton.tsx
からリアクションを選択し、選択された絵文字が全クライアントにブロードキャストされる。
-
各プログラム解説
1. app/ 配下
layout.tsx
1.1 import { Work_Sans } from "next/font/google";
import "./globals.css";
import { Room } from "./Room";
export const metadata = {
title: "Figma Clone",
description:
"A minimalist Figma clone using fabric.js and Liveblocks for realtime collaboration",
};
const workSans = Work_Sans({
subsets: ["latin"],
variable: "--font-work-sans",
weight: ["400", "600", "700"],
});
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<html lang='en'>
<body className={`${workSans.className} bg-primary-grey-200`}>
<Room>
{children}
</Room>
</body>
</html>
);
export default RootLayout;
解説
-
import { Work_Sans } from "next/font/google";
- Next.js 13 以降で推奨されるフォントの読み込み機能です。Google Fonts の「Work Sans」をプロジェクトに導入しています。
-
import "./globals.css";
- Tailwind等が定義されている
globals.css
をグローバルに適用。
- Tailwind等が定義されている
-
import { Room } from "./Room";
- 後述の
Room.tsx
からRoom
コンポーネントをインポート。 - レイアウト全体に Liveblocks のルーム(部屋)を適用し、アプリのどのページでもリアルタイム機能を使えるようにする狙いがあります。
- 後述の
-
export const metadata = {...}
- Next.js 13(App Router)の仕組みで、ページタイトルやディスクリプションなどを定義するためのオブジェクトです。
-
const workSans = Work_Sans({ ... })
- Work Sans を読み込み、ウェイトやサブセットを指定。
--font-work-sans
というカスタムプロパティにも適用しています。
- Work Sans を読み込み、ウェイトやサブセットを指定。
-
const RootLayout = ({ children }: { children: React.ReactNode }) => ( ... )
- Reactコンポーネントとして書かれたレイアウト関数。
children
にはこのレイアウト配下のページやコンポーネントが入ります。
- Reactコンポーネントとして書かれたレイアウト関数。
-
<html lang='en'> ... </html>
- HTML 全体のルートタグ。言語を英語に設定。
-
<body className={
${workSans.className} bg-primary-grey-200}>
- 先ほど読み込んだフォントのクラス
workSans.className
と、Tailwind のクラスbg-primary-grey-200
を適用。背景色を薄いグレーに。
- 先ほど読み込んだフォントのクラス
-
<Room>{children}</Room>
- ここで
Room
コンポーネントにラップすることで、すべての子要素が Liveblocks のルームに所属する形になります。
- ここで
page.tsx
1.2 "use client";
import Live from "@/components/Live";
export default function Page() {
return (
<div className="h-[100vh] w-full flex justify-center items-center text-center">
<Live />
</div>
);
}
解説
-
"use client";
- Next.js において、このコンポーネントがクライアント側でレンダリングされるという宣言。
-
import Live from "@/components/Live";
-
components/Live.tsx
から読み込んだLive
コンポーネントを使っています。
-
-
export default function Page() { ... }
- この
Page
コンポーネントがapp/
ディレクトリ直下のルート (/
) として機能します。
- この
-
<div className="h-[100vh] w-full flex justify-center items-center text-center">
- 画面全体を高さ100%(
100vh
)にし、中央に要素を寄せるスタイルをTailwindで実装。
- 画面全体を高さ100%(
-
<Live />
- 後述する
Live
コンポーネントを単純に呼び出しているだけのシンプルな構造。 - ここで描画される中身は、Liveblocks やカーソルの仕組みが詰まったコンポーネント側に委ねられています。
- 後述する
Room.tsx
1.3 "use client";
import { ReactNode } from "react";
import {
LiveblocksProvider,
RoomProvider,
ClientSideSuspense,
} from "@liveblocks/react/suspense";
export function Room({ children }: { children: ReactNode }) {
return (
<LiveblocksProvider publicApiKey={process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!}>
<RoomProvider id="my-room">
<ClientSideSuspense fallback={<div>Loading…</div>}>
{() => children}
</ClientSideSuspense>
</RoomProvider>
</LiveblocksProvider>
);
}
解説
-
"use client";
- こちらもクライアントコンポーネントとして動作。
-
import { LiveblocksProvider, RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";
-
Liveblocks の React 用ライブラリから機能をインポートしています。
- LiveblocksProvider : 全体のリアルタイム機能用コンテキストを作るコンポーネント
- RoomProvider : 特定のルーム(部屋)に参加するためのコンポーネント
- ClientSideSuspense : クライアントサイドの Suspense 機能
-
Liveblocks の React 用ライブラリから機能をインポートしています。
-
<LiveblocksProvider publicApiKey={process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!}>
-
.env
などに記載されているNEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY
を利用して認証。 -
!
は TypeScript の「非 null アサーションオペレータ」で、確実に存在する前提で利用。
-
-
<RoomProvider id="my-room">
-
id="my-room"
という名前の部屋に入る。 - 同じルームIDを共有しているユーザー同士はリアルタイムで状態を同期できます。
-
-
<ClientSideSuspense fallback={<div>Loading…</div>}>
- クライアントサイドでローディングが必要な時に
<div>Loading…</div>
を表示。 -
{() => children}
で子要素をレンダリング。
- クライアントサイドでローディングが必要な時に
これにより、Room
コンポーネント配下の要素(今回だと layout.tsx
の children
や page.tsx
が含まれるすべて)で Liveblocks の各種フックや状態が利用できるようになります。
2. components/ 配下
Live.tsx
2.1 import { useBroadcastEvent, useEventListener, useMyPresence, useOthers } from "@/liveblocks.config";
import LiveCursors from "./cursor/LiveCursors";
import React, { useCallback, useEffect, useState } from "react";
import CursorChat from "./cursor/CursorChat";
import { CursorMode, CursorState, Reaction, ReactionEvent } from "@/types/type";
import ReactionSelector from "./reaction/ReactionButton";
import FlyingReaction from "./reaction/FlyingReaction";
import useInterval from "@/hooks/useInterval";
const Live = () => {
const others = useOthers();
const [{ cursor }, updateMyPresence ] = useMyPresence() as any;
const [cursorState, setCursorState] = useState<CursorState>({
mode: CursorMode.Hidden,
});
const [reactions, setReactions] = useState<Reaction[]>([]);
const broadcast = useBroadcastEvent();
useInterval(() => {
setReactions((reactions) => reactions.filter((reaction) => reaction.timestamp > Date.now() - 4000));
}, 1000);
useInterval(() => {
if (cursorState.mode === CursorMode.Reaction && cursorState.isPressed && cursor) {
setReactions((reactions) =>
reactions.concat([
{
point: { x: cursor.x, y: cursor.y },
value: cursorState.reaction,
timestamp: Date.now(),
},
])
);
broadcast({
x: cursor.x,
y: cursor.y,
value: cursorState.reaction,
});
}
}, 100);
useEventListener((eventData) => {
const event = eventData.event as ReactionEvent;
setReactions((reactions) =>
reactions.concat([
{
point: { x: event.x, y: event.y },
value: event.value,
timestamp: Date.now(),
},
])
);
});
const handlePointerMove = useCallback((event: React.PointerEvent) => {
event.preventDefault();
if(cursor === null || cursorState.mode !== CursorMode.ReactionSelector ){
const x = event.clientX - event.currentTarget.getBoundingClientRect().x;
const y = event.clientY - event.currentTarget.getBoundingClientRect().y;
updateMyPresence({ cursor: { x, y} });
}
}, [cursor, cursorState.mode, updateMyPresence]);
const handlePointerLeave = useCallback(() => {
setCursorState({ mode: CursorMode.Hidden });
updateMyPresence({ cursor: { cursor: null, message: null } });
}, [updateMyPresence]);
const handlePointerDown = useCallback((event: React.PointerEvent) => {
const x = event.clientX - event.currentTarget.getBoundingClientRect().x;
const y = event.clientY - event.currentTarget.getBoundingClientRect().y;
updateMyPresence({ cursor: { x, y} });
setCursorState(( state: CursorState ) =>
cursorState.mode === CursorMode.Reaction
? {...state, isPressed: true}
: state
);
}, [cursorState.mode, updateMyPresence]);
const handlePointerUp = useCallback(() => {
setCursorState(( state: CursorState ) =>
cursorState.mode === CursorMode.Reaction
? {...state, isPressed: true}
: state
);
}, [cursorState.mode]);
useEffect(() => {
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "/") {
setCursorState({
mode: CursorMode.Chat,
previousMessage: null,
message: "",
});
} else if (e.key === "Escape") {
updateMyPresence({ message: "" });
setCursorState({ mode: CursorMode.Hidden });
} else if (e.key === "e") {
setCursorState({ mode: CursorMode.ReactionSelector });
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "/") {
e.preventDefault();
}
};
window.addEventListener("keyup", onKeyUp);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("keydown", onKeyDown);
};
}, [updateMyPresence]);
const setReaction = useCallback((reaction: string) => {
setCursorState({ mode: CursorMode.Reaction, reaction, isPressed: false });
}, []);
return (
<div
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
className="h-[100vh] w-full flex justify-center items-center text-center border-2 border-green-500"
>
<h1 className="text-2xl text-white">Liveblocks Figma cloen</h1>
{reactions.map((r) => (
<FlyingReaction
key={r.timestamp.toString()}
x={r.point.x}
y={r.point.y}
timestamp={r.timestamp}
value={r.value}
/>
))}
{cursor && (
<CursorChat
cursor={cursor}
cursorState={cursorState}
setCursorState={setCursorState}
updateMyPresence={updateMyPresence}
/>
)}
{cursorState.mode === CursorMode.ReactionSelector && (
<ReactionSelector
setReaction={setReaction}
/>
)}
<LiveCursors others={others} />
</div>
);
}
export default Live;
解説 (主要ポイント)
-
フック
-
useMyPresence()
: 自分の状態 (ここではcursor
やmessage
) を管理・更新するための Liveblocks フック。 -
useOthers()
: 他ユーザーの状態を配列として取得。 -
useBroadcastEvent()
: 全ユーザーへイベントをブロードキャストする仕組み。 -
useEventListener()
: 他ユーザーが送ってきたイベントを受け取る。
-
-
カーソル状態 (
cursorState
)-
mode
によって現在のカーソル機能が「Hidden」「Chat」「Reaction」「ReactionSelector」などに分岐。 - 例えば「
/
」キーを押すとチャットモードに切り替わり、エンターで送信できる。 - 「
e
」キーで絵文字リアクション選択モードに入る。
-
-
リアクション管理 (
reactions
)- リアクション(絵文字)を
FlyingReaction
コンポーネントに渡して画面に表示し、useInterval
で定期的に古いものを消す。 -
if (cursorState.mode === CursorMode.Reaction && cursorState.isPressed && cursor) { ... }
の部分で、マウスを押しっぱなしの間に連続して絵文字を放出する仕組み。
- リアクション(絵文字)を
-
ポインタイベントのハンドラ
-
handlePointerMove
: マウスを動かすたびに座標をupdateMyPresence
で送信し、他ユーザーにも共有。 -
handlePointerDown
,handlePointerUp
: リアクションモード時にクリック中かどうか(isPressed
)を管理。 -
handlePointerLeave
: マウスが要素外に出たらカーソルを非表示(mode: Hidden
)に変更。
-
-
キーボードイベント
-
useEffect
でグローバルにキーイベントを監視し、"/"
キーでチャット開始、"Escape"
でチャット取り消しなどを実装。
-
-
レンダリング部分
-
<h1 className="text-2xl text-white">Liveblocks Figma cloen</h1>
: タイトル(typo:「cloen」ですが、おそらく「clone」の誤字)。 -
{reactions.map((r) => <FlyingReaction ... />)}
: 複数の絵文字が飛び交う処理。 -
{cursor && <CursorChat ... />}
: 自分のカーソルにチャット入力ウィンドウを表示。 -
{cursorState.mode === CursorMode.ReactionSelector && <ReactionSelector ... />}
: 絵文字の選択UI表示。 -
<LiveCursors others={others} />
: 他ユーザーのカーソルをまとめて描画。
-
cursor/Cursor.tsx
2.2 import CursorSVG from "@/public/assets/CursorSVG";
type Props = {
color: string;
x: number;
y: number;
message: string;
}
const Cursor = ({ color, x, y , message }:Props) => {
return (
<div className="pointer-events-none absolute top-0 left-0" style={{ transform: `translateX(${x}px) translateY(${y}px)`}}>
<CursorSVG color={color} />
{message && (
<div
className='absolute left-2 top-5 rounded-3xl px-4 py-2'
style={{ backgroundColor: color, borderRadius: 20 }}
>
<p className='whitespace-nowrap text-sm leading-relaxed text-white'>
{message}
</p>
</div>
)}
</div>
);
}
export default Cursor;
解説
-
CursorSVG
はマウスカーソルのアイコン画像を React コンポーネント化したもの (SVG)。 -
style={{ transform:
translateX( {y}px){x}px) translateY( }}
で、画面上にカーソルを配置。 -
pointer-events-none
を付与し、ユーザーがカーソル要素をクリックするのを無効化している。 -
message
がある場合のみ吹き出し要素を表示。吹き出し背景はstyle={{ backgroundColor: color }}
で他ユーザーごとの色を変えられます。
cursor/CursorChat.tsx
2.3 import { CursorChatProps, CursorMode } from "@/types/type";
import CursorSVG from "@/public/assets/CursorSVG";
const CursorChat = ({ cursor, cursorState, setCursorState, updateMyPresence }: CursorChatProps) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
updateMyPresence({ message: e.target.value });
setCursorState({
mode: CursorMode.Chat,
previousMessage: null,
message: e.target.value,
});
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
setCursorState({
mode: CursorMode.Chat,
// @ts-ignore
previousMessage: cursorState.message,
message: "",
});
} else if (e.key === "Escape") {
setCursorState({
mode: CursorMode.Hidden,
});
}
};
return (
<div
className="absolute top-0 left-0"
style={{
transform: `translateX(${cursor.x}px) translateY(${cursor.y}px)`,
}}
>
{cursorState.mode === CursorMode.Chat && (
<>
<CursorSVG color="#000" />
<div
className="absolute left-2 top-5 bg-blue-500 px-4 py-2 text-sm leading-relaxed text-white"
onKeyUp={(e) => e.stopPropagation()}
style={{
borderRadius: 20,
}}
>
{cursorState.previousMessage && <div>{cursorState.previousMessage}</div>}
<input
className="z-10 w-60 border-none bg-transparent text-white placeholder-blue-300 outline-none"
autoFocus={true}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={cursorState.previousMessage ? "" : "Say something…"}
value={cursorState.message}
maxLength={50}
/>
</div>
</>
)}
</div>
);
};
export default CursorChat;
解説
-
CursorChatProps, CursorMode
は型定義が別ファイル(@/types/type
など)にまとめられている。 -
updateMyPresence({ message: e.target.value })
で入力値を更新し、他ユーザーにリアルタイムでチャット文字列を共有。 -
handleKeyDown
で Enter キー → メッセージを確定して消す、Escape キー → チャットキャンセル といった処理を実装。 -
autoFocus={true}
があるため、モードがチャットに切り替わった際、すぐに入力を開始できる。
cursor/LiveCursors.tsx
2.4 import Cursor from "./Cursor";
import { COLORS } from "@/constants";
import { LiveCursorProps } from "@/types/type";
const LiveCursors = ({ others }: LiveCursorProps) => {
return others.map(({ connectionId, presence }) => {
if (presence == null || !presence?.cursor) {
return null;
}
return (
<Cursor
key={connectionId}
color={COLORS[Number(connectionId) % COLORS.length]}
x={presence.cursor.x}
y={presence.cursor.y}
message={presence.message}
/>
);
});
};
export default LiveCursors;
解説
-
others
はuseOthers()
フックで取得したデータを想定し、ユーザーごとのconnectionId
とpresence
を持つ。 -
presence?.cursor
が存在するユーザーのみをマップして、Cursor
コンポーネントで描画。 -
COLORS
は別の定数定義ファイルにあり、複数の色が配列で管理されている想定。connectionId % COLORS.length
でユーザーごとに色を決定。
reaction/ReactionButton.tsx
2.5 import React from "react";
type Props = {
setReaction: (reaction: string) => void;
};
export default function ReactionSelector({ setReaction }: Props) {
return (
<div
className="absolute bottom-20 left-0 right-0 mx-auto w-fit transform rounded-full bg-white px-2"
onPointerMove={(e) => e.stopPropagation()}
>
<ReactionButton reaction="👍" onSelect={setReaction} />
<ReactionButton reaction="🔥" onSelect={setReaction} />
<ReactionButton reaction="😍" onSelect={setReaction} />
<ReactionButton reaction="👀" onSelect={setReaction} />
<ReactionButton reaction="😱" onSelect={setReaction} />
<ReactionButton reaction="🙁" onSelect={setReaction} />
</div>
);
}
function ReactionButton({
reaction,
onSelect,
}: {
reaction: string;
onSelect: (reaction: string) => void;
}) {
return (
<button
className="transform select-none p-2 text-xl transition-transform hover:scale-150 focus:scale-150 focus:outline-none"
onPointerDown={() => onSelect(reaction)}
>
{reaction}
</button>
);
}
解説
-
ReactionSelector
は絵文字ボタンをずらっと並べたUI。setReaction
が呼ばれると「何の絵文字を選択したか」が上位コンポーネント(Live.tsx
など)に伝わります。 -
onPointerMove={(e) => e.stopPropagation()}
は、ボタン上でのマウス移動が他要素に伝わらないようにするためのもの。 -
ReactionButton
でonPointerDown
イベントを使い、クリック時に絵文字を選択。 - ボタンに
hover:scale-150
を付け、マウスホバー時に拡大するアニメーションをTailwindで実装。
reaction/FlyingReaction.tsx
2.6 import styles from "./index.module.css";
type Props = {
x: number;
y: number;
timestamp: number;
value: string;
};
export default function FlyingReaction({ x, y, timestamp, value }: Props) {
return (
<div
className={`pointer-events-none absolute select-none ${
styles.disappear
} text-${(timestamp % 5) + 2}xl ${styles["goUp" + (timestamp % 3)]}`}
style={{ left: x, top: y }}
>
<div className={styles["leftRight" + (timestamp % 3)]}>
<div className="-translate-x-1/2 -translate-y-1/2 transform">
{value}
</div>
</div>
</div>
);
}
解説
-
FlyingReaction
コンポーネントは、選択された絵文字を画面上でアニメーション表示するためのもの。 -
timestamp % 5
を使って文字サイズ(text-2xl
~text-6xl
)を動的に変化させたり、timestamp % 3
で3パターンのアニメーション(goUp0
,goUp1
,goUp2
など)を切り替えて飽きのこない動きを演出。 -
styles["leftRight" + (timestamp % 3)]
といった形で、CSS モジュールのクラス名を組み立てています。 -
pointer-events-none
でこの要素がクリックなどの対象にならないように。
reaction/index.module.css
2.7 .goUp0 {
opacity: 0;
animation:
goUpAnimation0 2s,
fadeOut 2s;
}
@keyframes goUpAnimation0 {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(0px, -400px);
}
}
.goUp1 {
opacity: 0;
animation:
goUpAnimation1 2s,
fadeOut 2s;
}
@keyframes goUpAnimation1 {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(0px, -300px);
}
}
.goUp2 {
opacity: 0;
animation:
goUpAnimation2 2s,
fadeOut 2s;
}
@keyframes goUpAnimation2 {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(0px, -200px);
}
}
.leftRight0 {
animation: leftRightAnimation0 0.3s alternate infinite ease-in-out;
}
@keyframes leftRightAnimation0 {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(50px, 0px);
}
}
.leftRight1 {
animation: leftRightAnimation1 0.3s alternate infinite ease-in-out;
}
@keyframes leftRightAnimation1 {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(100px, 0px);
}
}
.leftRight2 {
animation: leftRightAnimation2 0.3s alternate infinite ease-in-out;
}
@keyframes leftRightAnimation2 {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(-50px, 0px);
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
解説
-
.goUp0
,.goUp1
,.goUp2
で上方向への移動アニメーション量( -400px, -300px, -200px )をそれぞれ変えています。 -
.leftRight0
,.leftRight1
,.leftRight2
は左右に揺れるアニメーションで、アニメーションの距離や方向を微妙に変えてバリエーションを出しています。 -
.fadeOut
は最終的にopacity: 0;
になるアニメーションを 2 秒かけて実行し、絵文字が消えていく表現。 -
FlyingReaction
コンポーネントでtimestamp % 3
を使ってクラスを付与しているため、ユーザーが多くの絵文字を連発してもランダム感を出せます。
4. 最後に
以上のように、本記事では「Next.js(App Router) と Liveblocks の組み合わせで、マウスカーソル共有やリアクタイムチャット・絵文字リアクションができるWebアプリ」を実装することができました。
より、深掘りしていきたいです。
Discussion