Lexcial + Yjsでの共同編集エディタを作成してみた


## はじめに
こんにちは！anyのプロダクトチームに所属している @310kaです。
この記事は any Product Team Advent Calendar2024 24日目の記事になります。

https://qiita.com/advent-calendar/2024/anyinc

我々が開発している[Qast](https://qast.jp/)はナレッジプラットフォームであり、社内でのナレッジを様々な形で蓄積し検索できるツールです。
ナレッジを残すためにはエディタが必要不可欠であり、エディタに対してナレッジを残すために様々な便利な機能開発を試みています。
今回は、今後機能開発を検討している同時編集を可能にするための実装をご紹介します。


## Lexicalについて
Qastはエディタに[Lexical](https://lexical.dev/)を採用しています。Lexicalは複数のブロックノードをツリー構造で持つエディタで様々なUI表現が可能であり、独自でカスタマイズしたノードを作成し組み込むことが容易です。
また、入力補助などのプラグインも多く、共同編集のために`Yjs`を利用した `@lexical/yjs`プラグインが作られています。

## Yjsについて
Yjsはリアルタイム共同編集を実現するフレームワークで、CRDT (Conflict-free Replicated Data Type ) というデータ構造を用いて複数のユーザーが同時に編集するときに共同操作を可能にしています。
Yjsはコアライブラリである [yjs](https://github.com/yjs/yjs), 同期プロトコルを定義した[y-protocols](https://github.com/yjs/y-protocols) に加えて各種サーバまわりのバックエンドの実装が存在します。
今回は[y-websocket](https://github.com/yjs/y-websocket)を使用したwebsocketによる実装を試してみます。

## 実装
Websocketサーバの実装ですが、今回はNestJSを使用します。
NestJSでは接続を監視するGatewayを実装しAppModuleに組み込むことでWebsocket接続が可能になります。

まずは、`y-websocket` を追加します。
```
pnpm i y-websocket yjs
```

以下は `y-websocket` の `setupWSConnection` を使用して接続する処理の例です。
```ts
import { Injectable } from '@nestjs/common';
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Request } from 'express';
import { Server, WebSocket } from 'ws';
// @ts-expect-error
import { setupWSConnection } from 'y-websocket/bin/utils';

@Injectable()
@WebSocketGateway({ transports: ['websocket'] })
export class YjsGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  async handleConnection(client: WebSocket, request: Request): Promise<void> {
    // ?room_id=ROOM_IDをdocNameとする
    const docName = new URLSearchParams(request.url.split('?')[1]).get('room_id') ?? 'roomId';
    setupWSConnection(client, request, { docName });
  }

  handleDisconnect(): void {}
}
```

AppModuleに組み込むことで動作します。
```ts
@Module({
  providers: [YjsGateway],
})
export class AppModule {}
```

次にフロントエンドの実装ですが、Lexicalの[CollaborationPlugin](https://lexical.dev/docs/collaboration/react) を使用するため`@lexical/yjs`を追加します。
```
pnpm i y-websocket yjs @lexical/yjs
```
まず `y-websocket` の `WebsocketProvider` を返却する `ProviderFactory` を実装します。

`Yjs`では`Y.Doc`で共有データを持ちます。 `Y.Doc`を同期することで複数の環境で同じ状態を保ち、`CollaborationPlugin`で`Lexical`の`Node`に変換して表示・編集を行なっています。

```ts
import type { ComponentProps } from 'react';
import React, { useCallback } from 'react';

import { CollaborationPlugin } from '@lexical/react/LexicalCollaborationPlugin';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';

type ProviderFactory = ComponentProps<typeof CollaborationPlugin>['providerFactory'];

// 複数のエディタを扱う想定でidごとにY.Docを管理できるようにします
const getDocFromMap = (id: string, yjsDocMap: Map<string, Y.Doc>): Y.Doc => {
  let doc = yjsDocMap.get(id);

  if (doc === undefined) {
    doc = new Y.Doc();
    yjsDocMap.set(id, doc);
  } else {
    doc.load();
  }

  return doc;
};

const providerFactory: ProviderFactory = useCallback(
  (id: string, yjsDocMap: Map<string, Y.Doc>): Provider => {
    const doc = getDocFromMap(id, yjsDocMap);

    // Y.DocをWebsocketProviderを通じて同期します
    return new WebsocketProvider(
      'ws://localhost:3000',
      `/editor?room_id=${id}`,
      doc,
    ) as unknown as Provider;
  },
  [],
);
```


次に `CollaborationPlugin` を `LexicalComposer` に他のプラグインと同じように追加します。
```tsx
<CollaborationPlugin
  id="test"
  providerFactory={providerFactory}
  shouldBootstrap={false}
  username={currentUser.name}
  initialEditorState={editor?.getEditorState()}
/>
```


ここまでの実装だけで共同編集が可能になります。非常に簡単ですね。
![yjs.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947755/9ade0ff3-b5f8-0367-3930-abd66cc193fe.gif)


共同編集自体はこれだけで可能になりますが、アプリケーションに組み込むためにはデータの永続化が必要不可欠です。
`y-websocket` には `leveldb` への保存処理が存在しますが、ここは各々の環境に応じた実装を追加する必要があるでしょう。
`y-redis` を利用するか各々の環境に応じた保存処理を [setPersistence](https://github.com/yjs/y-websocket/blob/master/bin/utils.cjs#L53) に実装することになります。
`y-websocket` 自体は比較的シンプルな作りになっているので、こちらをベースに拡張していくことになります。

`y-websocket` のデータはバイナリでやりとりされるため読み取ることができません。
`NestJS`では`@SubscribeMessage()` デコレータでメッセージイベントを受け取るハンドラーがありますが、バイナリなので受け取れないです。

サーバ側でのデータ取得に処理を入れたい場合は下記のようなAdapterを作成し `bootstrap`で`app.useWebSocketAdapter(new YjsWsAdapter(app))` をすれば `@SubscribeMessage('yjs')` で受け取ることができます。
データの中身を詳しく知るためには、 [y-protocols](https://github.com/yjs/y-protocols/blob/master/PROTOCOL.md)を理解していく必要があります。

```ts
import { WsAdapter } from '@nestjs/platform-ws';
import { MessageMappingProperties } from '@nestjs/websockets';
import { Observable } from 'rxjs';

export class YjsWsAdapter extends WsAdapter {
  bindMessageHandler(
    buffer: any,
    handlers: MessageMappingProperties[],
    transform: (data: any) => Observable<any>,
  ): Observable<any> {
    try {
      JSON.parse(buffer.data);
      return super.bindMessageHandler(buffer, handlers, transform);
    } catch {
      const messageHandler = handlers.find(handler => handler.message === 'yjs');
      const { callback } = messageHandler;
      return transform(callback(buffer));
    }
  }
}
```

今回は同時編集を検証するための実装でしたが、実際にQastへ組み込むためにはQast独自で拡張したLexical機能への対応や各ナレッジとの紐付け処理やLexicalでのシリアライズされたJSON形式での永続化、認証やスケーリングなど多岐に渡っての対応が必要になります。

## さいごに
Qastではナレッジを残す使命のために、様々な機能開発を試みています。エンジニア側として色んな実装検証や提案もしており、Qast全体の使い勝手をよくする試みや開発体験の向上など様々なタスクを進めています。
エディタにまつわる開発やインフラなどに興味ある方、是非一緒にやりましょう！参画をお待ちしております！

---

any株式会社では[ナレッジ経営クラウドQast](https://qast.jp/)のエンジニアを絶賛募集中です。
是非[採用ページ](https://anyinc.jp/recruit)をご覧ください！
興味がある方は、[こちら](https://hrmos.co/pages/anyinc/jobs?category=1938133004283695106)よりご応募お待ちしております。
エンジニア組織/文化について詳しく知りたい方は[こちら](https://speakerdeck.com/any_tech/engineer-culture-deck-v3-dot-0)。