Supabase RealtimeのBroadcast, Presenceを利用したキャンバス共有アプリを作る

目次

はじめに

SupabaseはWebSocketベースのRealtimeサーバを提供しています。
このサーバには大きく分けて三つの利用方法があります。

  • データベースの変更を同期するPostgres Changes
  • メッセージのリアルタイムな送受信を行うBroadcast
  • ユーザ間の状態を同期するPresence

このうち、データベースの変更を同期するPostgres Changesに関しては以前記事を作成しましたので見ていただければと思います。

今回はBroadcastPresenceを併用して同じキャンバスを共有して複数人で絵を描けるアプリを作成します。

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

SupabaseのTIPSリンク集
  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次