11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-12-23

はじめに

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

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

Lexicalについて

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

Yjsについて

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

実装

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

まずは、y-websocket を追加します。

pnpm i y-websocket yjs

以下は y-websocketsetupWSConnection を使用して接続する処理の例です。

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に組み込むことで動作します。

@Module({
  providers: [YjsGateway],
})
export class AppModule {}

次にフロントエンドの実装ですが、LexicalのCollaborationPlugin を使用するため@lexical/yjsを追加します。

pnpm i y-websocket yjs @lexical/yjs

まず y-websocketWebsocketProvider を返却する ProviderFactory を実装します。

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

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;
  },
  [],
);

次に CollaborationPluginLexicalComposer に他のプラグインと同じように追加します。

<CollaborationPlugin
  id="test"
  providerFactory={providerFactory}
  shouldBootstrap={false}
  username={currentUser.name}
  initialEditorState={editor?.getEditorState()}
/>

ここまでの実装だけで共同編集が可能になります。非常に簡単ですね。
yjs.gif

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

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

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

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のエンジニアを絶賛募集中です。
是非採用ページをご覧ください!
興味がある方は、こちらよりご応募お待ちしております。
エンジニア組織/文化について詳しく知りたい方はこちら

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?