はじめに
SupabaseはWebSocketベースのRealtimeサーバを提供しています。
このサーバには大きく分けて三つの利用方法があります。
- データベースの変更を同期するPostgres Changes
- メッセージのリアルタイムな送受信を行うBroadcast
- ユーザ間の状態を同期するPresence
このうち、データベースの変更を同期するPostgres Changes
に関しては以前記事を作成しましたので見ていただければと思います。
今回はBroadcast
とPresence
を併用して同じキャンバスを共有して複数人で絵を描けるアプリを作成します。
Supabaseの設定
今回特に設定は必要ないです。
いつも通りプロジェクトを立ち上げて、API URLとAnon Keyを控えておきましょう。
フロントエンド側実装
今回はVite経由でReactを利用して開発していきます。
npm create vite@latest
設定は基本デフォルトでよいですが、Reactかつ、TypeScriptで開発すると楽です。
プロジェクトの作成が終わったら、supabaseを利用するための準備をします。
Supabase連携
下記コマンドでSupabaseを利用するためのライブラリを導入します。
npm install @supabase/supabase-js
実装に移る前に必要な先ほど控えておいたSupabase側の情報を利用するための.envファイルを作成します。
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
CSS変更
CSSを変更します。
※reset.cssは自分の好きなものを利用してください
@import './reset.css';
body {
background-color: #f0f0f0;
overflow: hidden;
height: 100vh;
width: 100vw;
}
canvas {
border: 1px solid gray;
background-color: white;
}
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=500, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
App.tsx
ここからメインの実装に移ります。
まずはコード全体をお見せします。
import { useRef, useState, useEffect } from 'react';
import { createClient } from '@supabase/supabase-js';
import './App.css';
const supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY);
function App() {
const [users, setUsers] = useState<Set<string>>(new Set());
const [color, setColor] = useState<string>('#000000');
const [lineWidth, setLineWidth] = useState<number>(2);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [drawing, setDrawing] = useState(false);
const [context, setContext] = useState<CanvasRenderingContext2D | null>(null);
const [userId, setUserId] = useState<string>('');
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
const usersRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
setContext(ctx);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
}
}
}, [color, lineWidth]);
useEffect(() => {
if (!userId) {
const newUserId = Math.random().toString(36).substr(2, 9);
setUserId(newUserId);
}
const channel = supabase.channel('drawing_room');
channel
.on('broadcast', { event: 'draw' }, ({ payload }) => {
if (context && payload.userId !== userId) {
const { path, color: remoteColor, lineWidth: remoteLineWidth } = payload;
context.strokeStyle = remoteColor;
context.lineWidth = remoteLineWidth;
context.beginPath();
context.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
context.lineTo(path[i].x, path[i].y);
}
context.stroke();
}
});
channel
.on('presence', { event: 'sync' }, () => {
const newState = channel.presenceState();
const currentUsers = new Set<string>(usersRef.current);
if (Object.values(newState)[0]) {
Object.values(newState)[0].forEach((user: any) => {
currentUsers.add(user.user_id);
});
usersRef.current = currentUsers;
setUsers(new Set(currentUsers)); // レンダーのためにuseStateも更新
}
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
const currentUsers = new Set<string>(usersRef.current);
currentUsers.add(Object.values(newPresences)[0].user_id);
usersRef.current = currentUsers;
setUsers(new Set(currentUsers)); // レンダーのためにuseStateも更新
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
const currentUsers = new Set<string>(usersRef.current);
currentUsers.delete(Object.values(leftPresences)[0].user_id);
usersRef.current = currentUsers;
setUsers(new Set(currentUsers)); // レンダーのためにuseStateも更新
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
channel.track({ user_id: userId });
}
});
return () => {
channel.unsubscribe();
};
}, [context]);
const startDrawing = (x: number, y: number) => {
setDrawing(true);
setCurrentPath([{ x, y }]);
if (context) {
context.strokeStyle = color;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(x, y);
}
};
const draw = (x: number, y: number) => {
if (!drawing || !context) return;
setCurrentPath(prev => [...prev, { x, y }]);
context.lineTo(x, y);
context.stroke();
};
const endDrawing = () => {
setDrawing(false);
if (currentPath.length > 1) {
supabase.channel('drawing_room').send({
type: 'broadcast',
event: 'draw',
payload: { path: currentPath, color, lineWidth, userId },
});
}
setCurrentPath([]);
};
return (
<div>
<canvas
ref={canvasRef}
width={500}
height={500}
onMouseDown={(e) => startDrawing(e.nativeEvent.offsetX, e.nativeEvent.offsetY)}
onMouseUp={endDrawing}
onMouseLeave={endDrawing}
onMouseMove={(e) => draw(e.nativeEvent.offsetX, e.nativeEvent.offsetY)}
onTouchStart={(e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = e.currentTarget.getBoundingClientRect();
startDrawing(touch.clientX - rect.left, touch.clientY - rect.top);
}}
onTouchEnd={(e) => {
e.preventDefault();
endDrawing();
}}
onTouchMove={(e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = e.currentTarget.getBoundingClientRect();
draw(touch.clientX - rect.left, touch.clientY - rect.top);
}}
/>
<div>
<label>
色:
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
/>
</label>
<label>
線の太さ:
<input
type="range"
min="1"
max="20"
value={lineWidth}
onChange={(e) => setLineWidth(Number(e.target.value))}
/>
</label>
</div>
<div>
<h2>オンラインユーザー</h2>
<ul>
{Array.from(users).map((user) => (
<li key={user}>
{user} {user === userId ? '(あなた)' : ''}
</li>
))}
</ul>
</div>
</div>
);
}
export default App;
App.tsx ~Supabase利用部分~
Supabaseの利用部分を抜粋すると下記になります。
const supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY);
~~~
// 起動時
const channel = supabase.channel('drawing_room');
channel
.on('broadcast', { event: 'draw' }, ({ payload }) => {
if (context && payload.userId !== userId) {
const { path, color: remoteColor, lineWidth: remoteLineWidth } = payload;
context.strokeStyle = remoteColor;
context.lineWidth = remoteLineWidth;
context.beginPath();
context.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
context.lineTo(path[i].x, path[i].y);
}
context.stroke();
}
});
channel
.on('presence', { event: 'sync' }, () => {
const newState = channel.presenceState();
const currentUsers = new Set<string>(usersRef.current);
if (Object.values(newState)[0]) {
Object.values(newState)[0].forEach((user: any) => {
currentUsers.add(user.user_id);
});
usersRef.current = currentUsers;
setUsers(new Set(currentUsers));
}
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
const currentUsers = new Set<string>(usersRef.current);
currentUsers.add(Object.values(newPresences)[0].user_id);
usersRef.current = currentUsers;
setUsers(new Set(currentUsers));
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
const currentUsers = new Set<string>(usersRef.current);
currentUsers.delete(Object.values(leftPresences)[0].user_id);
usersRef.current = currentUsers;
setUsers(new Set(currentUsers));
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
channel.track({ user_id: userId });
}
});
~~~
// 線を書き終わったとき
const endDrawing = () => {
setDrawing(false);
if (currentPath.length > 1) {
supabase.channel('drawing_room').send({
type: 'broadcast',
event: 'draw',
payload: { path: currentPath, color, lineWidth, userId },
});
}
setCurrentPath([]);
};
Broadcast
- 自分で線を引き終わったときに線のデータを送信
- 誰かが引いた線のデータを受信
Presence
- いまどんなユーザがいるか、自分のユーザIDはどれか?を表示
をそれぞれ行っています。
キャンバスを作る部分は下記の記事を参考にさせていただき、色や線の太さを変更する機能を追加して作成しています。
https://qiita.com/ebkn/items/af3b53f560eb023a200f
これで完成です!
細かい部分は参考資料内にあるgithubリポジトリをご確認ください。
参考資料
公式資料:https://supabase.com/docs/guides/realtime
githubリポジトリ:https://github.com/TodoONada/supabase-realtime-canvas-test
- Next.js + SupabaseでTodoアプリ作成 CRUDの基本を学ぼう
- FirebaseとSupabaseの機能、料金、セキュリティ、AI観点などを含めて徹底比較
- Next.js とSupabaseで求人マッチングアプリを作る①
- 素早くアプリを作りたい! Vercel+Supabaseでプロジェクト開発
- Supabaseの機能一覧・解説 オープンソースのFirebase代替mBaaS
- Supabase CLIコマンド一覧
- Supabase RealtimeのBroadcast, Presenceを利用したキャンバス共有アプリを作る
- Supabaseのローカル開発環境を構築して開発体験を向上させつつ料金も節約
- Next.js + Supabaseでリアルタイムチャットを作ろう
- Next.js + Supabaseでソーシャルログインを実装する方法
- Next.js+Supabaseで認証機能を実装しよう【コード付き完全ガイド】
- Supabase + Next.jsで画像投稿アプリを最適化する(画像圧縮、ファイルサイズ制限、ファイルのアップロード数制限)
- OpenAI Embeddings API+ Supabase Vector Database + Next.jsでベクトル検索を実装する
- Supabase + AWS CognitoでAmplifyを利用せずサードパーティ認証する
- Supabaseでデータ更新があった時にSlack通知を送る
- Supabase Branchingでプレビュー環境を手に入れて開発体験を向上する
- Next.js + SupabaseでGraphQLを利用する方法
- SupabaseのEdge FunctionsとSendGridでメールを一斉送信する
- Supabaseで匿名認証とアカウントの本登録への昇格の方法
- Next.js + SupabaseでStorageを利用した画像投稿アプリ作成
- Next.js + SupabaseでRLSを利用して安全なアプリを作ろう。
- Next.js + SupabaseでAuth + Storageのストレージサービスを作る方法
- Next.jsとSupabaseで認証機能ありのリアルタイムチャットを作成する。
- Next.jsとSupabaseで認証つきチャットアプリを作成する(SNS風UI)
- Next.js + SupabaseでSMS認証を作成する方法
- Next.jsとSupabaseで全文検索を実装する方法
- Next.jsとSupabaseで作成したチャットアプリにお知らせ機能を実装する
- Nextjs + SupabaseでSupabaseのStorage Image Transformationsを利用する方法
- Supabase vs Neonの比較。機能や料金、セキュリティ、AIを徹底比較
- Supabaseのセルフホスティングをできるだけ安く行う方法
- Supabase + Cognitoの連携で外部連携を試す
- Supabase CLIのTesting機能を利用する
- Supabaseを利用する際に設定しておきたい項目