📝

1時間で完成!【Liveblocks】とNext.jsによるリアルタイム共同編集リッチテキストエディタの作り方

2023/12/09に公開

記事概要

この記事では、「Liveblocks」とNext.jsを使用し、新規作成したNext.jsプロジェクトから以下の機能を持ったwebページを作成します。

  • マウスカーソル位置の共有
  • リアルタイム共同編集エディタ

「Liveblocks」とは

https://liveblocks.io/

Liveblocksは、あなたの製品にパフォーマンス性の高いコラボレーション機能を驚くほど速く組み込むための完全なツールキットを開発者に提供します。

トップページでは以下の特徴を上げています。

  • WebSocket infrastructure: すぐに使えるエッジネットワークと信頼性の高い接続エンジン
  • Zero configuration: 数百万人規模のホスティングとスケーリング。複雑な設定は必要ありません。
  • Effortless scaling: あらゆるトラフィックに対応できるよう設計されています。
  • No maintenance required: インフラを維持するのではなく、構築することに時間を使いましょう。

大雑把にまとめると
共同作業に特化したWebsocketAPIを提供する、スケーラブルなフルマネージドサービス」のようです。夢がありますね。

料金体系

https://liveblocks.io/docs/platform/plans
詳細はページを見ていてただくとして、趣味用途であれば十分な無料枠が提供されています。

  • 無制限のプロジェクト
  • 毎月最大100人の共同編集ユーザー
  • 月間アクティブユーザー数5,000 人
  • 1Roomあたり最大20の同時接続

Liveblocksのアカウント、API KEYの用意

signupすると {アカウント名}'s teamというteamと、開発と本番のProjectが作られます。
今回はDevelopmentを使用するので、プロジェクトページへ移動します

開いたら、左の[API keys]をクリックし、Secret keyの[Reveal key]を押してSecret keyをコピーしてください。
また、今回は [Public key]は利用しないため、キー右上のトグルボタンをオフにしてください。

Next.jsの用意

create-next-app を使用して生成したNext.jsのコードに各機能を実装するため、まずは create-next-app を使用してリポジトリを作成します。

npx create-next-app@latest liveblocks-nextjs-example

このあとに出てくる質問はすべて初期状態のままEnterを押して進めてください。

Liveblocksの設定

作成したリポジトリでLiveblocksの設定を入れていきます。
https://liveblocks.io/docs/get-started/nextjs

npm install @liveblocks/client @liveblocks/react @liveblocks/node
npx create-liveblocks-app@latest --init --framework react

このあとに出てくる質問はすべて初期状態のままEnterを押して進めてください

これでリポジトリ直下にLiveblocksの設定ファイルとなる liveblocks.config.ts ができました。
今回は publicApiKey ではなく認証エンドポイントを利用するため、ファイル冒頭の client を定義している部分を以下のように変更してください。

liveblocks.config.ts
const client = createClient({
-  // publicApiKey: "",
-  // authEndpoint: "/api/auth",
+  authEndpoint: "/api/auth",
   // throttle: 100,
});

次に、リポジトリ直下に .env.local を作成し、事前に取得したLiveblocksのSecret keyをファイル内に以下のように記載してください。
REPLACEのところを取得したキーに置き換えてください

.env.local
LIVEBLOCKS_API_KEY='REPLACE'

Liveblocksの認証APIを作成

configで設定した authEndpoint: "/api/auth" に認証APIを実装します。

ユーザー情報の型を定義するため、 liveblocks.config.tsUserMeta 型を以下のように変更します

liveblocks.config.ts
export type UserMeta = {
-  // id?: string,  // Accessible through `user.id`
-  // info?: Json,  // Accessible through `user.info`
+  info?: {
+    name: string,
+    color: string,
+    picture: string,
+  },
};

今回はRoute Handlerを使用しているため、app/api/auth/route.ts にAPIを実装します

app/api/auth/route.ts
import {Liveblocks} from "@liveblocks/node";
import {NextRequest, NextResponse} from "next/server";
import {nanoid} from "nanoid";
import {UserMeta} from "@/liveblocks.config";

const liveblocks = new Liveblocks({
    secret: process.env.LIVEBLOCKS_API_KEY!,
});

export async function POST(request: NextRequest) {
    const userInfo: UserMeta["info"] = {
        // 表示名は niwatori で固定にしています
        name: 'niwatori',
        // カーソルなどに使用する色のカラーコードをランダム生成しています
        color: '#' + (0x1000000 + Math.random() * 0xffffff).toString(16).substr(1, 6),
        // 画像は固定
        picture: 'https://avatars.githubusercontent.com/u/6592938?v=4',
    }

    const liveSession = liveblocks.prepareSession(nanoid(), {userInfo});

    const {room} = await request.json();
    liveSession.allow(room, liveSession.FULL_ACCESS);

    const {body, status} = await liveSession.authorize();
    return new NextResponse(body, {status});
}

以上でAPIの実装は完了です。

LiveblocksのRoomコンポーネントを作成

フロントエンドに同期を行う基盤コンポーネントを作成します。

app 以下に、LiveblocksのRoom参加機能を提供するWrapperコンポーネントを作成します。

app/LiveBlocksWrapper.tsx
'use client'

import {RoomProvider} from "@/liveblocks.config";
import {ClientSideSuspense} from "@liveblocks/react";
import React from "react";

export const LiveBlocksWrapper: React.FC<{ children: React.ReactNode }> = ({children}) => {
    return (
        <RoomProvider id="my-room" initialPresence={{}}>
            <ClientSideSuspense fallback="Loading…">
                {() => children}
            </ClientSideSuspense>
        </RoomProvider>
    );
}

作成したWrapperをトップページapp/page.tsx に導入します(Next.js初期ページの中身はすべて削除します)

app/page.tsx
import {LiveBlocksWrapper} from "@/app/LiveBlocksWrapper";

export default function Home() {
  return (
    <main>
      <LiveBlocksWrapper>
          <></>
      </LiveBlocksWrapper>
    </main>
  )
}

この状態で npm run dev でウェブページを見てみましょう

画面は真っ黒ですが、 検証ツールで WS(WebSocket)の項目を開くと、何やら通信が行われています。
また、別のタブで開くことでも通信が入ってることがわかります。

カーソル同期機能を実装

以上で同期機能を実装する準備が出来たので、まずはカーソル同期機能を実装してみます

カーソルコンポーネントを作成

app/Cursor.tsxに表示するカーソルを作成します。

app/Cursor.tsx
import React from "react";

type Props = {
    x: number;
    y: number;
    color: string;
    name: string;
    image: string;
};

export const Cursor: React.FC<Props> = ({ x, y, color, name, image }) => {
    return (
        <svg
            style={{
                position: "absolute",
                left: 0,
                top: 0,
                transform: `translateX(${x}px) translateY(${y-10}px)`,
            }}
            width="260"
            height="64"
            viewBox="0 0 260 64"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
        >
            <text x="0" y="16" fill={color} fontWeight={'bold'} >👈{name}</text>
            <image href={image} x="0" y="20" height="20" width="20" />
        </svg>
    );
}

カーソル状態を同期するようconfigを修正

まず、リアルタイムで同期したいユーザー状態の型を定義するため、 liveblocks.config.tsPresence 型を以下のように変更します

liveblocks.config.ts
type Presence = {
  cursor: { x: number; y: number } | null;
};

カーソル状態を同期するコンポーネントを作成

自分のカーソル状態(Presence)を送信し、他ユーザのカーソル状態を表示するコンポーネントをapp/CursorSyncWrapper.tsxに作成します。

app/CursorSyncWrapper.tsx
'use client'

import {useMyPresence, useOthers} from "@/liveblocks.config";
import React from "react";
import {Cursor} from "@/app/Cursor";

export const CursorSyncWrapper: React.FC<{ children: React.ReactNode }> = (props) => {
    const [myPresence, updateMyPresence] = useMyPresence();
    const others = useOthers();
    const handlePointerMove = (e: any) => {
        const cursor = {x: Math.floor(e.clientX), y: Math.floor(e.clientY)};
        updateMyPresence({cursor});
    };
    const handlePointerLeave = (e: any) => {
        updateMyPresence({cursor: null});
    };
    return <div
        onPointerMove={handlePointerMove}
        onPointerLeave={handlePointerLeave}
        className={"h-screen w-screen"}
    >
        {others
            .filter((other) => other.presence.cursor !== null)
            .map((other) => (
                <Cursor
                    key={other.connectionId}
                    x={other.presence.cursor?.x!}
                    y={other.presence.cursor?.y!}
                    color={other.info?.color! as string}
                    name={other.info?.name! as string}
                    image={other.info?.picture! as string}
                />
            ))}
        {props.children}
    </div>
}

作成したWrapperをトップページapp/page.tsx に導入します(初期ページの中身はすべて削除)

app/page.tsx
import {LiveBlocksWrapper} from "@/app/LiveBlocksWrapper";
import {CursorSyncWrapper} from "@/app/CursorSyncWrapper";

export default function Home() {
    return (
        <main>
            <LiveBlocksWrapper>
-                <></>
+                <CursorSyncWrapper>
+                    <></>
+                </CursorSyncWrapper>
            </LiveBlocksWrapper>
        </main>
    )
}

以上でカーソル同期の実装は完了です。
ウェブページを2個開き、片方でカーソルを動かすともう片方に同期されることがわかります。

共同編集エディタを実装

次は共同編集可能なエディタを実装してみます。
エディタには Lexical を使用します。

Lexicalの導入

まずはオフラインで動作するLexicalエディタを配置しましょう。

npm install @lexical/react 

app/Editor.tsx にエディタコンポーネントの追加

app/Editor.tsx
'use client'

import {LexicalComposer} from "@lexical/react/LexicalComposer";
import {RichTextPlugin} from "@lexical/react/LexicalRichTextPlugin";
import {ContentEditable} from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import {TRANSFORMERS} from '@lexical/markdown';
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
import {CodeNode, CodeHighlightNode} from "@lexical/code";
import {AutoLinkNode, LinkNode} from "@lexical/link";
import {ListNode, ListItemNode} from "@lexical/list";
import {HeadingNode, QuoteNode} from "@lexical/rich-text";
import React from "react";

export const Editor: React.FC = () => {

    // Lexical config
    const initialConfig = {
        namespace: "LiveblocksDemo",
        nodes: [
            HeadingNode,
            ListNode,
            ListItemNode,
            QuoteNode,
            CodeNode,
            CodeHighlightNode,
            AutoLinkNode,
            LinkNode,
        ],
        theme: {},
        onError: (error: unknown) => {
            throw error
        },
    };

    return <div className={'p-8'}>
        <div className={"w-[1000px] text-lg p-8 leading-loose bg-slate-900 text-white rounded-xl border-2"}>
            <LexicalComposer initialConfig={initialConfig}>
                <RichTextPlugin
                    contentEditable={<ContentEditable/>}
                    placeholder={
                        <div>未入力</div>
                    }
                    ErrorBoundary={LexicalErrorBoundary}
                />
                <MarkdownShortcutPlugin transformers={TRANSFORMERS}/>
            </LexicalComposer>
        </div>
    </div>
}

app/globals.css に以下を追記

app/globals.css
[contenteditable]:focus {
  outline: 0px solid transparent;
}

app/page.tsx にエディタを設定

app/page.tsx
import {LiveBlocksWrapper} from "@/app/LiveBlocksWrapper";
import {CursorSyncWrapper} from "@/app/CursorSyncWrapper";
import {Editor} from "@/app/Editor";

export default function Home() {
  return (
    <main>
      <LiveBlocksWrapper>
          <CursorSyncWrapper>
-              <></>
+              <Editor />
          </CursorSyncWrapper>
      </LiveBlocksWrapper>
    </main>
  )
}

以上でLexicalで作ったエディタが画面上に表示されます。
マークダウンプラグインが入っているため、# などのマークダウン記法で修飾が可能です

コラボレーション機能を実装

LiveblocksでYjsを利用するためのパッケージをインストール

npm install @liveblocks/yjs

app/Editor.tsx にコラボレーション機能を追加

app/Editor.tsx
'use client'

import {LexicalComposer} from "@lexical/react/LexicalComposer";
import {RichTextPlugin} from "@lexical/react/LexicalRichTextPlugin";
import {ContentEditable} from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import {TRANSFORMERS} from '@lexical/markdown';
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
import {CodeNode, CodeHighlightNode} from "@lexical/code";
import {AutoLinkNode, LinkNode} from "@lexical/link";
import {ListNode, ListItemNode} from "@lexical/list";
import {HeadingNode, QuoteNode} from "@lexical/rich-text";
import React from "react";
+import LiveblocksProvider from "@liveblocks/yjs";
+import * as Y from "yjs";
+import {
+    $createParagraphNode,
+    $createTextNode,
+    $getRoot,
+    LexicalEditor,
+} from "lexical";
+import {useRoom, useSelf} from "@/liveblocks.config";
+import {CollaborationPlugin} from "@lexical/react/LexicalCollaborationPlugin";
+import {Provider} from "@lexical/yjs";
+
+function initialEditorState(editor: LexicalEditor): void {
+    const root = $getRoot();
+    const paragraph = $createParagraphNode();
+    const text = $createTextNode();
+    paragraph.append(text);
+    root.append(paragraph);
+}

export const Editor: React.FC = () => {
+   const room = useRoom();
+   const userInfo = useSelf((me) => me.info);

    // Lexical config
    const initialConfig = {
        namespace: "LiveblocksDemo",
        nodes: [
            HeadingNode,
            ListNode,
            ListItemNode,
            QuoteNode,
            CodeNode,
            CodeHighlightNode,
            AutoLinkNode,
            LinkNode,
        ],
        theme: {},
        onError: (error: unknown) => {
            throw error
        },
    };

    return <div className={'p-8'}>
        <div className={"w-[1000px] text-lg p-8 leading-loose bg-slate-900 text-white rounded-xl border-2"}>
            <LexicalComposer initialConfig={initialConfig}>
                <RichTextPlugin
                    contentEditable={<ContentEditable/>}
                    placeholder={
                        <div>未入力</div>
                    }
                    ErrorBoundary={LexicalErrorBoundary}
                />
                <MarkdownShortcutPlugin transformers={TRANSFORMERS}/>
+               <CollaborationPlugin
+                   id="yjs-plugin"
+                   providerFactory={(id, yDocMap) => {
+                       const yDoc = new Y.Doc();
+                       yDocMap.set(id, yDoc);
+                       return new LiveblocksProvider(room, yDoc) as Provider;
+                   }}
+                   initialEditorState={initialEditorState}
+                   shouldBootstrap={true}
+                   cursorColor={userInfo?.color}
+                   username={userInfo?.name}
+               />
            </LexicalComposer>
        </div>
    </div>
}

LeixicalにCollaborationPlugin を追加し、 LiveblocksProvider を利用してroom情報との連携を追加しています。

以上で共同編集が可能なエディタが実装できました、動作を確認してみましょう 🎉

今回作成したリポジトリ
https://github.com/howyi/liveblocks-nextjs-example

まとめ

このような共同編集機能の実装は、フロントエンドやバックエンドの開発者にとって複雑な作業となりがちです。
しかし、Liveblocksを使用すれば簡単にリアルタイムAPIを実装できました。
ドキュメントも非常に充実していて独自の認証やルームの制限なども柔軟に変更できます。
また、今回使用した機能以外にも CommentsBroadcast 等、様々な機能が提供されています。

公式では共有ホワイトボードの実装サンプルなどもあります。
https://liveblocks.io/examples/collaborative-whiteboard-advanced/nextjs-whiteboard-advanced

個人的に嬉しいところが、liveblocks.config.ts を通して型定義を提供していることです。
これにより、プロダクトに合わせて同期するデータ項目を型安全に設計できます。

プロダクトへの実際の採用にはさらなる検証が必要ですが、現時点での使い勝手は非常に良好でした。

みなさんも是非Liveblocksでコラボレーション機能を実装してみてはいかがでしょうか。

参考資料

https://liveblocks.io/docs/api-reference/liveblocks-react#Presence
https://lexical.dev/docs/getting-started/react
https://lexical.dev/docs/collaboration/react
https://nextjs.org/docs/app/building-your-application/routing/route-handlers

Discussion