🚀

【React/Next.js】Liveblocksでのリアルタイムコラボレーション開発

2023/09/27に公開

Liveblocksとは?

Liveblocks | Collaborative experiences in days, not months

Liveblocksは、開発者向けに、高性能なコラボレーション機能を極めて迅速に製品に組み込むための完全なツールキットを提供します。

早速 Sign up して使ってみたいと思います。

image1.png

アカウント作成し、ダッシュボードに移動すると既に DevelopmentProduction の2つのプロジェクトが作成されています。

また、MAUや1Room内での同時接続数も表示されています。

image2.png

ホワイトボードのベース実装

今回は Whiteboard + React を作る想定で進めていこうと思っているので、以下のチュートリアルを元に進めていきます。

Tutorials - Creating a collaborative online whiteboard with React and Liveblocks | Liveblocks documentation

動作環境

"dependencies": {
    "@liveblocks/client": "^1.2.1",
    "@liveblocks/react": "^1.0.9",
    "next": "13.4.13",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/node": "20.4.8",
    "@types/react": "^18.2.6",
    "@types/react-dom": "^18.2.4",
    "eslint": "8.46.0",
    "eslint-config-next": "13.4.13",
    "prettier": "^2.8.8",
    "typescript": "^5.0.4"
  }

APIキーの取得

まずは事前に使用するAPIキーを取得します。Public keyとSecret keyどちらでも使えるようですが、今回はチュートリアル同様Public keyを使って進めていきたいと思います。

↓ドキュメント内のinfomation

公開鍵と秘密鍵
秘密鍵を使うと、誰がルームにアクセスできるかを制御できます。これはより安全ですが、自分自身のバックエンドエンドポイントが必要です。このチュートリアルでは、公開鍵を使用します。詳細については、認証ガイドを参照してください。

Development プロジェクトに移動し、「API keys」をクリックします。

image3.png

Public key」の内容を控え、env.local に以下内容で設定しときます。

NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY=pk_xxxxxxxxxxxxxx

必要なパッケージのインストール

yarn add @liveblocks/client @liveblocks/react

providers/liveblocks.ts を以下内容で作成します。

import { createClient } from '@liveblocks/client';

const client = createClient({
  publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY ?? '',
});

Roomの作成

@liveblocks/reactcreateRoomContext を使ってRoomProviderとフックを作成し、コンポーネントから簡単にコンシュームできるようにします。先程の providers/liveblocks.ts に追加していきます。

import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react'; // 追加

const client = createClient({
  publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY ?? '',
});

export const { RoomProvider } = createRoomContext(client); // 追加

app/layout.tsxRoomProvider を適用していきます。

'use client';

import { RoomProvider } from './liveblocks.config'; // 追加

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <head></head>
      <body>
        {/* RoomProviderで囲む */}
        <RoomProvider id="react-whiteboard-app" initialPresence={{}}>
          <div>{children}</div>
        </RoomProvider>
      </body>
    </html>
  );
}

RoomProviderに包まれたすべてのコンポーネントは、この部屋と対話するために使用する特別なReactフックにアクセスできるようになります。

Canvasの作成

LiveMapを使って、部屋のストレージ内に図形のマップを保存していきます。

LiveMapは、Liveblocksが提供するストレージの一種です。LiveMapはJavaScriptのマップのようなものですが、そのアイテムは異なるクライアント間でリアルタイムに同期されます。複数のユーザーが同時にアイテムを挿入・削除しても、LiveMapはルーム内のすべてのユーザーに対して一貫性を保つことができます。

RoomProviderinitialStorage Propsからストレージを初期化します。

'use client';

import { LiveMap } from '@liveblocks/client'; // 追加
import { RoomProvider } from './liveblocks.config';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <head></head>
      <body>
        <RoomProvider
          id="react-whiteboard-app"
          initialPresence={{}}
          initialStorage={{ // 追加
            shapes: new LiveMap(),
          }}
        >
          <div>{children}</div>
        </RoomProvider>
      </body>
    </html>
  );
}

createRoomContextuseMap を使う事で、liveblocks内に保存されているStorageにアクセスする事ができます。

export const { RoomProvider, useMap } = createRoomContext(client);

useMap は接続中は null を返すので、nullの場合はIndicatorを表示させておくと良さそうです。

app/page.tsx を以下内容で作成します。

'use client';

import { useMap } from '../providers/liveblocks';

const Rectangle = ({ shape }: { shape: any }) => {
  const { x, y, fill } = shape;

  return (
    <div
      className="rectangle"
      style={{
        transform: `translate(${x}px, ${y}px)`,
        backgroundColor: fill ? fill : '#CCC',
      }}
    ></div>
  );
};

const Canvas = ({ shapes }: { shapes: any }) => {
  return (
    <>
      <div className="canvas">
        {Array.from(shapes, ([shapeId, shape]) => {
          return <Rectangle key={shapeId} shape={shape} />;
        })}
      </div>
    </>
  );
};

const PageWrapper = () => {
  const shapes = useMap('shapes');

  if (shapes == null) {
    return <div className="loading">Loading</div>;
  }
  return <Canvas shapes={shapes} />;
};

const Page = () => {
  return <PageWrapper />;
};
export default Page;

最後に app/global.css を以下内容で作成し、 app/layout.tsx でimportします。

body {
  background-color: #eeeeee;
}

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  width: 100vw;
}

.canvas {
  background-color: #eeeeee;
  touch-action: none;
  width: 100vw;
  height: 100vh;
}

.rectangle {
  position: absolute;
  /* transition: all 0.1s ease; */
  stroke-width: 1;
  border-style: solid;
  border-width: 2px;
  height: 100px;
  width: 100px;
}

.toolbar {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  padding: 4px;
  border-radius: 8px;
  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  display: flex;
  background-color: #ffffff;
  user-select: none;
}

.toolbar button {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px 8px;
  border-radius: 4px;
  background-color: #f8f8f8;
  color: #181818;
  border: none;
  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  margin: 4px;
  font-weight: 500;
  font-size: 12px;
}

.toolbar button:hover,
.toolbar button:focus {
  background-color: #ffffff;
}

.toolbar button:active {
  background-color: #eeeeee;
}

app/layout.tsx

import './globals.css';

長方形を追加できるようにする

チュートリアル通りに以下を app/page.tsx 追加します。

const COLORS = ['#DC2626', '#D97706', '#059669', '#7C3AED', '#DB2777'];

const getRandomInt = (max: number) => {
  return Math.floor(Math.random() * max);
};

const getRandomColor = () => {
  return COLORS[getRandomInt(COLORS.length)];
};

// ...

const Canvas = ({ shapes }: { shapes: any }) => {
  // insertRectangle 追加
  const insertRectangle = () => {
    const shapeId = Date.now().toString();
    const rectangle = {
      x: getRandomInt(300),
      y: getRandomInt(300),
      fill: getRandomColor(),
    };
    shapes.set(shapeId, rectangle);
  };
  return (
    <>
      <div className="canvas">
        {Array.from(shapes, ([shapeId, shape]) => {
          return <Rectangle key={shapeId} shape={shape} />;
        })}
      </div>
      {/* ↓追加 */}
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
      </div>
    </>
  );
};
// ...

image4.png

「Rectangle」ボタンが追加され、クリックで長方形を追加できるようになりました!

Selectionの追加

新たに useMyPresenceuseOthers を使います。

export const { RoomProvider, useMap, useOthers, useMyPresence } =
  createRoomContext(client);

Rectangle をクリックした際のイベントと選択時のカラーを設定できるようにします。

const Rectangle = ({
  shape,
  id,
  onShapePointerDown,
  selectionColor,
}: {
  shape: any;
  id: string;
  onShapePointerDown: (e: any, id: string) => void;
  selectionColor: string | undefined;
}) => {
  const { x, y, fill } = shape;

  return (
    <div
      className="rectangle"
      onPointerDown={(e) => onShapePointerDown(e, id)}
      style={{
        transform: `translate(${x}px, ${y}px)`,
        backgroundColor: fill ? fill : '#CCC',
        borderColor: selectionColor || 'transparent',
      }}
    ></div>
  );
};

CanvasuseMyPresenceuseOthers を使って選択状態を反映できるようにします。

const Canvas = ({ shapes }: { shapes: any }) => {
  const [{ selectedShape }, setPresence] = useMyPresence();
  const others = useOthers();

  const insertRectangle = () => {
    const shapeId = Date.now().toString();
    const rectangle = {
      x: getRandomInt(300),
      y: getRandomInt(300),
      fill: getRandomColor(),
    };
    shapes.set(shapeId, rectangle);
  };

  const onShapePointerDown = (e: any, shapeId: string) => {
    setPresence({ selectedShape: shapeId });
    e.stopPropagation();
  };

  return (
    <>
      <div
        className="canvas"
        onPointerDown={(e) => setPresence({ selectedShape: null })}
      >
        {Array.from(shapes, ([shapeId, shape]) => {
          const selectionColor =
            selectedShape === shapeId
              ? 'blue'
              : others.some((user) => user.presence?.selectedShape === shapeId)
              ? 'green'
              : undefined;
          return (
            <Rectangle
              key={shapeId}
              shape={shape}
              id={shapeId}
              onShapePointerDown={onShapePointerDown}
              selectionColor={selectionColor}
            />
          );
        })}
      </div>
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
      </div>
    </>
  );
};

image5.gif

長方形を削除

Canvas に以下を追加します。

const deleteRectangle = () => {
    shapes.delete(selectedShape);
    setPresence({ selectedShape: null });
  };
// ...
return (
    <>
      ...
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
        {/* 以下を追加 */}
        <button onClick={deleteRectangle} disabled={selectedShape == null}>
          Delete
        </button>
      </div>
    </>
  );

image6.gif

長方形の移動同期

Canvas を修正していきます。

const Canvas = ({ shapes }: { shapes: any }) => {
  const [isDragging, setIsDragging] = useState(false); // 追加
  const [{ selectedShape }, setPresence] = useMyPresence();
  const others = useOthers();

  const onShapePointerDown = (e: any, shapeId: string) => {
    e.stopPropagation();
    setPresence({ selectedShape: shapeId });
    setIsDragging(true); // 追加
  };
  // ↓追加
  const onCanvasPointerUp = (e: any) => {
    if (!isDragging) {
      setPresence({ selectedShape: null });
    }

    setIsDragging(false);
  };
  // ↓追加
  const onCanvasPointerMove = (e: any) => {
    e.preventDefault();

    if (isDragging) {
      const shape = shapes.get(selectedShape);
      if (shape) {
        shapes.set(selectedShape, {
          ...shape,
          x: e.clientX - 50,
          y: e.clientY - 50,
        });
      }
    }
  };

  return (
    <>
      <div
        className="canvas"
        onPointerDown={(e) => setPresence({ selectedShape: null })}
        onPointerMove={onCanvasPointerMove} // 追加
        onPointerUp={onCanvasPointerUp} // 追加
      >
      ...
      </div>
    </>
  );
};

image7.gif

まとめ

今回試してみてリアルタイムコラボレーション機能が簡単に組み込めて、CRDTを用いたデータストアが使えるのがとても魅力的に感じました。
あとは1RoomにMAUの上限があるので、そこを考慮して組み込む必要がありそうかなと思います。

今回試した分は以下のリポジトリにアップしてます。

Slowhand0309/liveblocks-example: Liveblocks whiteboard + React example.

Discussion