Nuxt3 + Tiptap + Y.js でリアルタイム共同編集が可能なエディターを作る

共同編集マヂ便利
2023.02.20

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

NotionGoogle Docsなど、最近のオンラインドキュメントエディターでは、リアルタイムで複数人による共同編集が可能なサービスをよく見かけます。
便利ですよね、リアルタイムでの共同編集。 自分も日々お世話になっています。

最近、お仕事で上記サービスのような共同編集が可能なエディターを実装する機会があったのですが、そちらで採用した Tiptap というエディターライブラリがかなりいい感じだったので、今回その実装を簡単なサンプルとして紹介してみたいと思います。

なお、今回作成したサンプルのソースコードはGitHubに公開しています。
https://github.com/amotz/nuxt-tiptap-sample

完成イメージ

検証環境

  • nuxt 3.2.0
  • tiptap 2.0.0-beta.217
  • y-prosemirror 1.0.20
  • y-websocket 1.4.5
  • yjs 13.5.45

Nuxt3 プロジェクト作成

今回のサンプルはフロントエンドのフレームワークとしてNuxt 3を利用するので、まずは適当に新しいNuxtプロジェクトを作成しておきます。

$ npx nuxi init nuxt-tiptap-sample

Tiptapを使ったリッチテキストエディター実装

次に、ユーザーで編集可能なリッチテキストエディターを Tiptap を使って実装してみます。

Tiptapとは

Tiptap はリッチテキストエディターのツールキットであるProseMirrorのヘッドレスラッパーで、サクッとWYSIWYGエディターを構築できるオープンソースのライブラリです。

公式ドキュメントが充実しており、デフォルトで多くの拡張機能が用意されているためカスタマイズ性も良好なライブラリになっています。(一部の拡張機能は有償としてプライベートレジストリで提供)
Tiptap はVue.js(Nuxt.js)/React(Next.js)/Svelteなど、様々なフレームワークで利用することができます。

また、共同編集機能も提供しており、リアルタイムでのコラボレーションを実現することができます。

Tiptap インストール&コンポーネント実装

それでは、実際に以下を参考にしてTiptapをインストールしてみます。
Tiptap Documentation - Installation - Vue.js 3

$ npm install @tiptap/vue-3 @tiptap/pm @tiptap/starter-kit

インストールできたら、以下のようにエディター用のコンポーネントを実装します。
Tiptapはヘッドレスなので、雑にCSSでスタイルも設定しておきます。

$ npm install sass --save-dev

components/TiptapEditor.vue

<template>
  <editor-content :editor="editor" />
</template>

<script>
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
export default {
  components: {
    EditorContent,
  },
  data() {
    return {
      editor: null,
    }
  },
  mounted() {
    this.editor = new Editor({
      content: '',
      extensions: [
        StarterKit,
      ],
    })
  },
  beforeDestroy() {
    this.editor.destroy()
  },
}
</script>

<style lang="scss">
/* Basic editor styles */
.ProseMirror {
  > * + * {
    margin-top: 0.75em;
  }
  border: 1px solid #e0e0e0 !important;
  box-sizing: border-box;
  border-radius: 3px;
  padding: 16px 16px 16px 16px;
  font-size: 14px;
  code {
    background-color: rgba(#616161, 0.1);
    color: #616161;
  }
}
.content {
  padding: 1rem 0 0;
  h3 {
    margin: 1rem 0 0.5rem;
  }
  pre {
    border-radius: 5px;
    color: #333;
  }
  code {
    display: block;
    white-space: pre-wrap;
    font-size: 0.8rem;
    padding: 0.75rem 1rem;
    background-color:#e9ecef;
    color: #495057;
  }
}
</style>

アプリ側もコンポーネントを利用するように修正してみます。

app.vue

<template>
  <div>
    <client-only>
      <tiptap-editor />
    </client-only>
  </div>
</template>
<script>
import TiptapEditor from '~/components/TiptapEditor.vue'
export default {
  components: {
    TiptapEditor
  }
}
</script>

これで最低限エディターが動作する準備ができたので、ローカルでNuxtを起動してみます。

$ npm run dev

シンプルなWYSIWYGエディターが動作するようになりました。

Tiptap Extension + Y.jsによる共同編集の実装

エディターが実装できたので、次はリアルタイムで共同編集が実現できるような実装を追加してみます。
Tiptap では共同編集用の拡張ライブラリが用意されており、Y.jsと組み合わせることでリアルタイムの共同編集を実現できます。

Tiptap Documentation - Collaborative editing

Y.jsとは

今回共同編集の仕組みとして利用するY.jsは、クライアント間でリアルタイムにデータを同期するモジュラーフレームワークです。
WebSocketやWebRTCといったプロトコルを介して、クライアント間でデータを同期させます。
また、Y.jsではCRDTというデータ構造を用いて複数クライアントでの同時編集における衝突を解決しています。

CRDTやY.jsの理解については、以下がとても参考になりました。ありがとうございます!

共同編集用のライブラリインストール

それでは、共同編集に必要なライブラリをインストールしていきます。
今回はY.jsでデータ同期を行うためのプロバイダーとしてy-websocketを利用します。

$ npm install @tiptap/extension-collaboration yjs y-websocket y-prosemirror

共同編集用のWebSocketサーバー起動

y-websocketにはサーバーが実装されているので、ローカル環境でNuxtとは別にWebSocketサーバーを起動しておきます。

$ HOST=localhost PORT=1234 npx y-websocket

コンポーネント側の実装

コンポーネント側は、TiptapのExtensionを設定し共同編集用のプロバイダーとして先ほど起動したWebSocketサーバーのURL ws://localhost:1234 を指定します。

components/TiptapEditor.vue

<template>
  <editor-content :editor="editor" />
</template>
<script>
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";

export default {
  components: {
    EditorContent,
  },
  data() {
    return {
      editor: null,
    }
  },
  mounted() {
    const ydoc = new Y.Doc();
    this.provider = new WebsocketProvider("ws://localhost:1234", "sample-document", ydoc);
    
    this.editor = new Editor({
      content: '',
      extensions: [
        StarterKit,
        Collaboration.configure({
          document: ydoc,
        }),
      ],
    })
  },
  beforeDestroy() {
    this.editor.destroy()
    this.provider.destroy();
  },
}
</script>
(以下省略)

これで共同編集の実装ができたので、Nuxtのアプリを起動してみます。

$ npm run dev

リアルタイムに共同編集ができるエディターになりました!

共同編集者にカーソルを付ける

無事に共同編集ができるエディターが実装できたのですが、これだと誰がどこを編集しているのか分かりづらいため、編集者にカーソルを付けてみます。

Tiptap Documentation - Collaborative editing - Show other cursors

共同編集用のカーソルを実装するためのExtensionをインストールします。

$ npm install @tiptap/extension-collaboration-cursor

コンポーネント側もカーソルを出すような実装を追加します。
(今回はカーソルに表示させるユーザー名とカーソル色はランダムとしています)

<template>
  <editor-content :editor="editor" />
</template>

<script>
import { Editor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";

import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";

export default {
  components: {
    EditorContent,
  },

  data() {
    return {
      editor: null,
      provider: null,
    };
  },

  mounted() {
    const ydoc = new Y.Doc();
    this.provider = new WebsocketProvider(
      "ws://localhost:1234",
      "sample-document",
      ydoc
    );

    this.editor = new Editor({
      content: "",
      extensions: [
        StarterKit,
        Collaboration.configure({
          document: ydoc,
        }),
        CollaborationCursor.configure({
          provider: this.provider,
          user: { name: this.getRandomName(), color: this.getRandomColor() },
        }),
      ],
    });
  },

  beforeDestroy() {
    this.editor.destroy();
    this.provider.destroy();
  },

  methods: {
    getRandomColor() {
      const list = [
        "#958DF1",
        "#F98181",
        "#FBBC88",
        "#FAF594",
        "#70CFF8",
        "#94FADB",
        "#B9F18D",
      ];
      return list[Math.floor(Math.random() * list.length)];
    },

    getRandomName() {
      const list = [
        "Lea Thompson",
        "Cyndi Lauper",
        "Tom Cruise",
        "Madonna",
        "Jerry Hall",
        "Joan Collins",
        "Winona Ryder",
        "Christina Applegate",
        "Alyssa Milano",
        "Molly Ringwald",
        "Ally Sheedy",
        "Debbie Harry",
        "Olivia Newton-John",
        "Elton John",
        "Michael J. Fox",
        "Axl Rose",
        "Emilio Estevez",
        "Ralph Macchio",
        "Rob Lowe",
        "Jennifer Grey",
        "Mickey Rourke",
        "John Cusack",
        "Matthew Broderick",
        "Justine Bateman",
        "Lisa Bonet",
      ];
      return list[Math.floor(Math.random() * list.length)];
    },
  },
};
</script>

<style lang="scss">
/* Basic editor styles */
.ProseMirror {
  > * + * {
    margin-top: 0.75em;
  }

  border: 1px solid #e0e0e0 !important;
  box-sizing: border-box;
  border-radius: 3px;
  padding: 16px 16px 16px 16px;

  ul,
  ol {
    padding: 0 1rem;
  }

  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    line-height: 1.1;
  }

  code {
    background-color: rgba(#616161, 0.1);
    color: #616161;
  }

  pre {
    background: #0d0d0d;
    color: #fff;
    font-family: "JetBrainsMono", monospace;
    padding: 0.75rem 1rem;
    border-radius: 0.5rem;

    code {
      color: inherit;
      padding: 0;
      background: none;
      font-size: 0.8rem;
    }
  }

  mark {
    background-color: #faf594;
  }

  img {
    max-width: 100%;
    height: auto;
  }

  hr {
    margin: 1rem 0;
  }

  blockquote {
    padding-left: 1rem;
    border-left: 2px solid rgba(#0d0d0d, 0.1);
  }

  hr {
    border: none;
    border-top: 2px solid rgba(#0d0d0d, 0.1);
    margin: 2rem 0;
  }

  ul[data-type="taskList"] {
    list-style: none;
    padding: 0;

    li {
      display: flex;
      align-items: center;

      > label {
        flex: 0 0 auto;
        margin-right: 0.5rem;
        user-select: none;
      }

      > div {
        flex: 1 1 auto;
      }
    }
  }
}
/* Give a remote user a caret */
.collaboration-cursor__caret {
  position: relative;
  margin-left: -1px;
  margin-right: -1px;
  border-left: 1px solid #0d0d0d;
  border-right: 1px solid #0d0d0d;
  word-break: normal;
  pointer-events: none;
}

/* Render the username above the caret */
.collaboration-cursor__label {
  position: absolute;
  top: -1.4em;
  left: -1px;
  font-size: 12px;
  font-style: normal;
  font-weight: 600;
  line-height: normal;
  user-select: none;
  color: #0d0d0d;
  padding: 0.1rem 0.3rem;
  border-radius: 3px 3px 3px 0;
  white-space: nowrap;
}
</style>

Nuxtのアプリを起動してみます。

$ npm run dev

編集者のカーソルが表示されるようになりました!分かりやすくていい感じになりましたね。

おわりに

Nuxt3 + Tiptap + Y.js でリアルタイム共同編集が可能なエディターを実装してみました。
今回Tiptapを初めて使いましたが、拡張性が高そう&ドキュメントも充実しており、素晴らしいライブラリだなと感じました。

どなたかの参考になれば幸いです。