1時間で完成!【Liveblocks】とNext.jsによるリアルタイム共同編集リッチテキストエディタの作り方
記事概要
この記事では、「Liveblocks」とNext.jsを使用し、新規作成したNext.jsプロジェクトから以下の機能を持ったwebページを作成します。
- マウスカーソル位置の共有
- リアルタイム共同編集エディタ
「Liveblocks」とは
Liveblocksは、あなたの製品にパフォーマンス性の高いコラボレーション機能を驚くほど速く組み込むための完全なツールキットを開発者に提供します。
トップページでは以下の特徴を上げています。
- WebSocket infrastructure: すぐに使えるエッジネットワークと信頼性の高い接続エンジン
- Zero configuration: 数百万人規模のホスティングとスケーリング。複雑な設定は必要ありません。
- Effortless scaling: あらゆるトラフィックに対応できるよう設計されています。
- No maintenance required: インフラを維持するのではなく、構築することに時間を使いましょう。
大雑把にまとめると
「共同作業に特化したWebsocketAPIを提供する、スケーラブルなフルマネージドサービス」のようです。夢がありますね。
料金体系
詳細はページを見ていてただくとして、趣味用途であれば十分な無料枠が提供されています。
- 無制限のプロジェクト
- 毎月最大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の設定を入れていきます。
npm install @liveblocks/client @liveblocks/react @liveblocks/node
npx create-liveblocks-app@latest --init --framework react
このあとに出てくる質問はすべて初期状態のままEnterを押して進めてください
これでリポジトリ直下にLiveblocksの設定ファイルとなる liveblocks.config.ts
ができました。
今回は publicApiKey
ではなく認証エンドポイントを利用するため、ファイル冒頭の client
を定義している部分を以下のように変更してください。
const client = createClient({
- // publicApiKey: "",
- // authEndpoint: "/api/auth",
+ authEndpoint: "/api/auth",
// throttle: 100,
});
次に、リポジトリ直下に .env.local
を作成し、事前に取得したLiveblocksのSecret keyをファイル内に以下のように記載してください。
REPLACEのところを取得したキーに置き換えてください
LIVEBLOCKS_API_KEY='REPLACE'
Liveblocksの認証APIを作成
configで設定した authEndpoint: "/api/auth"
に認証APIを実装します。
ユーザー情報の型を定義するため、 liveblocks.config.ts
の UserMeta
型を以下のように変更します
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を実装します
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コンポーネントを作成します。
'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初期ページの中身はすべて削除します)
import {LiveBlocksWrapper} from "@/app/LiveBlocksWrapper";
export default function Home() {
return (
<main>
<LiveBlocksWrapper>
<></>
</LiveBlocksWrapper>
</main>
)
}
この状態で npm run dev
でウェブページを見てみましょう
画面は真っ黒ですが、 検証ツールで WS(WebSocket)の項目を開くと、何やら通信が行われています。
また、別のタブで開くことでも通信が入ってることがわかります。
カーソル同期機能を実装
以上で同期機能を実装する準備が出来たので、まずはカーソル同期機能を実装してみます
カーソルコンポーネントを作成
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.ts
の Presence
型を以下のように変更します
type Presence = {
cursor: { x: number; y: number } | null;
};
カーソル状態を同期するコンポーネントを作成
自分のカーソル状態(Presence)を送信し、他ユーザのカーソル状態を表示するコンポーネントを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
に導入します(初期ページの中身はすべて削除)
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
にエディタコンポーネントの追加
'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
に以下を追記
[contenteditable]:focus {
outline: 0px solid transparent;
}
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
にコラボレーション機能を追加
'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情報との連携を追加しています。
以上で共同編集が可能なエディタが実装できました、動作を確認してみましょう 🎉
今回作成したリポジトリ
まとめ
このような共同編集機能の実装は、フロントエンドやバックエンドの開発者にとって複雑な作業となりがちです。
しかし、Liveblocksを使用すれば簡単にリアルタイムAPIを実装できました。
ドキュメントも非常に充実していて独自の認証やルームの制限なども柔軟に変更できます。
また、今回使用した機能以外にも Comments や Broadcast 等、様々な機能が提供されています。
公式では共有ホワイトボードの実装サンプルなどもあります。
個人的に嬉しいところが、liveblocks.config.ts
を通して型定義を提供していることです。
これにより、プロダクトに合わせて同期するデータ項目を型安全に設計できます。
プロダクトへの実際の採用にはさらなる検証が必要ですが、現時点での使い勝手は非常に良好でした。
みなさんも是非Liveblocksでコラボレーション機能を実装してみてはいかがでしょうか。
参考資料
Discussion