ProseMirrorで作るちょっとだけリッチなテキストエディタ

はじめに

Backlogは昨年(2021年)メンション機能を導入しました。本文中に他のユーザーの名前を書くと、相手に通知が送られるというものです。それにともないこのメンション機能が使える箇所で使うテキスト編集コンポーネント(以下、エディタ)を作り直しました。

編集中のテキストに含まれるメンションを、見た目的にもデータ的にも通常のテキストとは異なるものとして扱えるようにするためです。

Backlogの以前のエディタはtextarea要素で実装されていました。textarea要素はプレーンなテキストしか扱えず、テキストの一部を太字にするとか背景色をつけるといった機能はありません。

ブラウザ上でテキストエディタを実装する手段には、textareaのほかにcontenteditable属性をtrueにした要素を使う選択肢もあります。この場合はHTMLの表現力が使えるのでメンションの表示をいい感じにするのは訳なさそうです。しかしcontenteditable=”true”にした要素上でのテキスト編集の挙動はブラウザごとに違いがあり、複数のブラウザをサポートするサービスで使うものを自分で作るのは開発やメンテナンスの手間がかかりそうです。

ありがたいことに最近はcontenteditableを有効にした要素をベースとしたリッチテキストエディタのライブラリがいろいろあります。DOM要素に割り当てるだけでリッチな機能が揃ったエディタになるものもあれば、部品だけが提供されていて自分でエディタを組み立てる形のものまでさまざまです。おおよそ手軽さとカスタマイズ性の間のトレードオフでいろいろなライブラリが存在しているようです。

今回私たちはこの手のライブラリの中からProseMirrorを使うことにしました。ProseMirrorは自分で組み立てるタイプのライブラリです。エディタの開発時はJavaScriptで書かれていましたが、その後にTypeScriptに置き換えられています。今回作ったのはプレーンテキストエディタにメンション機能がついただけの比較的単純なものなので、必要な機能だけを組み合わせる形のツールの方が向いていると考えてこれを選びました。

この記事では今回Backlogのメンション対応で作ったものを念頭に置きつつ、ProseMirrorでエディタを作るのがどういう感じなのかをお伝えしたいと思います。

いちばん単純なエディタ

ProseMirrorで作ることができる、もっとも機能が少ないエディタは次のようになります。

import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"

const schema = new Schema({
  nodes: {
    doc: { content: "text*" },
    text: {},
  },
})
const state = EditorState.create({schema})
new EditorView(document.getElementById("app"), {state})

このスクリプトをHTMLから読み込むとid属性が”app”の要素の下にエディタがつくられます。このエディタに「こんにちは」と入力すると、次のような見た目になります。

このエディタでは改行することも文字を修飾することもできません。最低限の機能でできることは、入力された文字を画面に反映することだけです。undoやredoもできません。

エディタが上図の状態の時、DOMとしては次のようになっています。

<div contenteditable="true" translate="no" class="ProseMirror">こんにちは</div>

このエディタ実装からはProseMirrorでエディタを組み立てるのに最低限必要な要素がわかります。エディタはドキュメントデータの構造(Schema)、ドキュメントの状態(EditorState)、エディタの表示部分(EditorView)の組み合わせでできています。

エディタで編集するドキュメントのデータはツリー構造をしています。例えば上図のエディタが保持しているドキュメントのデータの構造は次のようになります。

docタイプのノードがルートで、その下にtextノードがあります。

このドキュメントの構造を決めているのがスキーマです。スキーマはSchemaオブジェクトで表されます。Schemaのコンストラクタにはスキーマを表すオブジェクトを渡します。このオブジェクトのnodesプロパティはドキュメントで使うノードの種類を定義します。docとtextは必須のノード種別です。docはツリーのルートでドキュメント全体を表し、textはツリーの末端のテキストを表します。

エディタの状態を表すEditorStateは、ドキュメントのデータの他に、現在のカーソル位置や、次に入力されたテキストに適用する装飾(太字や斜体など)の情報を保持しています。

ユーザーがエディタ上で何か操作をすると、エディタはまず状態を更新してから次に更新された状態を画面に反映します。

複雑なドキュメントを扱えるようにするには、スキーマ定義でノードの種類を増やしたり、状態を更新するためのさまざまな操作を追加していくことになります。

面倒に思えるかもしれませんが、典型的なエディタの構築例や、編集操作を作るための基本的な部品などもライブラリとして提供されているので、一から何もかも作らなければならないわけではありません。例をコピーしてから不要なものを削っていくという作り方もできます。ProseMirrorの公式サイトにエディタの構成例のページがあるので、最初はここにあるものを参考にするとよいと思います。

改行できるようにする

ProseMirrorベースのエディタに機能を増やすときにどういうことをするのかを見てみましょう。

次のコードは、最初のエディタに改行機能を追加したものです。

// 略
import { baseKeymap } from "prosemirror-commands"
import { keymap } from "prosemirror-keymap"

const schema = new Schema({
  nodes: {
    doc: { content: "paragraph+" },
    paragraph: {
      content: "text*",
      toDOM: () => ["p", 0],
    },
    text: {},
  },
})

const state = EditorState.create({
  schema,
  plugins: [keymap(baseKeymap)]
})
new EditorView(document.getElementById("app"), {state})

このバージョンでは、Enterキーを押すと改行され、行頭でBackspaceを押すとカーソルがあった行とその直前の行が連結されます。

このエディタにテキストを入力してみます。

このとき、エディタが保持しているデータは次のようになっています。

また、このデータはエディタ中のDOM要素としては次のようになります。

<p>これは一行目</p><p>これは二行目</p><p>これは三行目</p>

このエディタのスキーマはdoc、paragraph、textの3種類のノードでできています。最初の例で説明したようにdocとtextは必須のノード種別です。新たに追加されたparagraphはエディタ独自のノード種別で、エディタ上にHTMLとしてどのように表示するのかを指定する必要があります。それをしているのがtoDOM関数です。このコードでは子要素をpタグで囲うよう指定しています。エディタの画面表示で<p>タグで囲まれている部分がparagraphノードに対応します。

プラグインで機能を増やす

次にEditorStateの作成箇所を見ると、EditorState.createの引数にpluginsというプロパティが追加されています。ここにはエディタに追加するプラグインのリストを指定します。エディタの機能追加は基本的にプラグインとして実装してここに追加していくことになります。

エディタでキー入力などのイベントが発生すると、それを処理する機会は最初にプラグインに回されます。pluginsに指定したプラグインの配列の先頭から順に処理を実行します。

いずれのプラグインでも処理されなかったイベントは、エディタ本体で通常の文字入力などとして扱われます。

keymap関数でキー入力に操作を割り当てる

このコードに登場しているkeymap関数はプラグインを作るためのユーティリティ関数です。keymap関数にはキー入力と処理の対応表を表すオブジェクトを渡します。

declare function keymap(bindings: {
    [key: string]: Command;
}): Plugin;

keymap関数が返す値は「キー操作が発生したときにそれに対応する処理を実行する」という機能を提供するプラグインです。keymap関数はprosemirror-keymapパッケージが提供します。keymap関数に渡しているbaseKeyMapオブジェクトは、テキストエディタによくあるキー操作と処理をまとめたものです。EnterキーやBackspaceキーを押した時の処理もここに含まれています。

キー入力に応じて実行される処理はCommandという型になっています。

declare type Command = (
    state: EditorState,
    dispatch?: (tr: Transaction) => void,
    view?: EditorView
) => boolean;

その戻り値はbooleanで、コマンド内部で実際には何もしなかった場合にfalseを返す約束になっています。例えばコマンドを連結して大きなコマンドを作るときに、falseを返すことで次のコマンドに処理の機会を回します。

自分で独自にコマンドを実装してキーマップに割り当てることもできます。

const myCommand: Command = (state, dispatch): boolean => {
  // do something
}
keymap({
  ...baseKeymap, 
  "Ctrl-j": myCommand, // CTRLキーを押しながらJキーを押すとmyCommandが実行される
})

keymap関数のようにライブラリが用意したものではなく、一から自分でプラグインを実装するときはPluginオブジェクトを作ります。

const myPlugin = new Plugin({
  props: {
    handleClick: (view, pos, event) => {
      console.log("clicked")
      return false
    }
  }
})
const state = EditorState.create({
  schema,
  plugins: [
    keymap(baseKeymap),
    myPlugin,
  ]
})

文字を装飾できるようにする

次にテキストを見た目を変えられるようにしてみましょう。次のコードはエディタの中で太字を使えるようにしたものです。

// 略
import { toggleMark } from "prosemirror-commands"

const schema = new Schema({
  nodes: {
    doc: { content: "paragraph+" },
    paragraph: {
      content: "text*",
      toDOM: () => ["p", 0],
    },
    text: {},
  },
  marks: {
    bold: {
      toDOM: () => ["b", 0]
    }
  }
})
const state = EditorState.create({
  schema,
  plugins: [
    keymap({
      ...baseKeymap,
      "Mod-b": toggleMark(schema.marks.bold),
    })
  ]
})
new EditorView(document.getElementById("app"), {state})

このエディタで一部の文字を太字にすると次のようになります。

このときDOMツリー上では次のようになっています。

<p>ふつうの文字と<b>太い文字</b></p>

また、エディタの状態中では下図のような構造のデータとして保持されています。

テキストの見た目に関する項目はスキーマの中のmarksに定義します。このスキーマではboldという項目が定義されています。これが適用されたテキストは<b>タグで囲まれます。marksで定義された項目はtextノードに適用されます。

textノードには複数種類の装飾を適用することができます。例えば太字と斜体を個別の項目としてmarksに定義しておいて、テキストの同じ部分に両方を適用することができます。テキストはそれに適用されるmarksの組み合わせが変わるところでノードが分けられます。

このエディタではboldを適用するかどうか切り替えるために、keymapに”Mod-b”のキー入力に操作を割り当てています。”Mod-b”はWindowsでは”Ctrl-b”、macOSでは”⌘-b”に対応します。toggleMarkは引数に与えた装飾の種類のオン・オフを切り替えるコマンドを返す関数です。これはprosemirror-commandsパッケージが提供します。テキストを範囲選択している場合は選択範囲内のテキストの文字の太さが変わります。

メンションを入れられるようにする

次はBacklogのエディタで使えるようになったメンションを使えるようにしてみます。
複数行に対応したエディタのスキーマを再掲します。

const schema = new Schema({
  nodes: {
    doc: { content: "paragraph+" },
    paragraph: {
      content: "text*",
      toDOM: () => ["p", 0],
    },
    text: {},
  },
})

このスキーマでは、ドキュメント全体はパラグラフの集まりで、各パラグラフは何の装飾もないただのテキストです。パラグラフを行と見なせば、これはプレーンテキストエディタのドキュメントの構造そのものです。

これを発展させてテキストのなかにメンション用のノードを混ぜこめるようにしてみます。スキーマは次のようになります。

const schema = new Schema({
  nodes: {
    doc: { content: "paragraph+" },
    paragraph: {
      content: "inline*",
      toDOM: () => ["p", 0],
    },
    text: { group: "inline" },
    mention: {
      group: "inline",
      inline: true,
      attrs: { id: { default: 0 }, name: { default: "?" } },
      toDOM: (node) =>
        ["span", { class: "mention", "data-id": node.attrs.id }, "@" + node.attrs.name]
    },
  },
})

メンションを含んだテキストは次のようになります。

このスキーマではメンションのDOMは次のようになります。

<span class="mention" data-id="100">@foo</span>

ノードの親子関係の指定方法にも変化があります。textノードとmentionノードに group: “inline” が指定されています。そしてparagraphノードのcontentプロパティが”text*”から”inline*”に変わっています。これによってparagraphノードは子ノードとしてtextノードまたはmentionを取るという意味になります。

ドキュメントを更新する

メンションは通常の文字のようにキーボードで直接入力できません。テキストの中にメンションを入れるには

  1. mentionノードのオブジェクトを作る
  2. 作ったノードをカーソル位置に挿入する

という操作をProseMirrorのAPIを使って実行します。コードにすると次のinsertMention()関数のようになります。

const view = new EditorView(/*略*/)
// ドキュメントの現在のカーソル位置にメンションノードを作る
const insertMention = (user: { id: number, name: string }) => {
  const tr = view.state.tr
  const selection = view.state.selection
  const mentionNode = schema.nodes.mention.create(user)
  tr.replaceWith(selection.from, selection.to, mentionNode)
  tr.insertText(" ")
  const newState = view.state.apply(tr)
  view.updateState(newState)
}
for (let i = 1; i <= 3; i++) {
  insertMention({id: i, name: `foo ${i}`})
}

これを実行するとエディタに三つのメンションが表示されます。

insertMention()の一行目のtrはトランザクションと呼ばれるオブジェクトです。リレーショナルデータベースにおけるトランザクションと同じようなものです。ドキュメントの更新はこのトランザクションを使って実行します。上の例ではreplaceWith()とinsertText()という操作を実行してから最後にそれをまとめて現在の状態に適用ています。EditorState.apply()は自信が保持している状態を更新するのではなく新しい状態を作って返します。その新しい状態をEditorView.updateState()に与えるとエディタの内容が更新されます。

改行を入れる操作や太字のオン・オフを切り替える操作も、その中では上の例と同じようにトランザクションを使ってドキュメントの状態を更新しています。

クリップボードの内容をペーストする

メンションをドキュメントに入れるのと同じように手で文字を入力する以外の手段でドキュメントを変更する操作に「クリップボードからのペースト」があります。

一般にクリップボードには複数のデータ形式でデータが保持されています。ProseMirrorで作ったエディタはpasteイベントの処理時に、HTML形式のデータが利用できる場合はそれを、なければプレーンテキスト形式のデータを使ってエディタに挿入するデータを作ります。

どちらの場合も、おおまかな流れは次のようになります。

  1. クリップボードにある文字列をDOMノードに変換する
  2. DOMノードをDOMParser.parseSlice()でProseMirrorのSliceオブジェクトに変換する
  3. 現在の選択範囲(カーソル位置)をSliceオブジェクトで置き換える

DOMParserはDOMのノードからProseMirrorのNodeを作る役割を持ちます。 Sliceオブジェクトはドキュメントの断片を表すデータです。

EditorViewのインスタンス作成時のオプションで、ペースト処理の挙動をカスタマイズできます。それを利用するとDOMParser.parseSlice()に渡す前の文字列を加工したり、DOMParserのインスタンスを差し替えたりできます。それでもどうにもならなければ、ペースト処理を丸ごと置き換えることもできます。それにはEditorViewにhandlePasteを指定します。

new EditorView(document.getElementById("app"),
  {
    state,
    handlePaste: (view: EditorView, event: ClipboardEvent, slice: Slice) => {
      // 自前のペースト処理
      return true
    }
  }
}

この関数にはClipboardEventイベントが渡されるので、クリップボードのデータに直接アクセスできます。

Backlogのメンション対応エディタではこのhandlePasteを使っています。クリップボードにHTMLのデータがある場合にもプレーンテキストのデータを優先して使いたかったためです。このエディタはProseMirrorを使ってはいるものの機能としてはほぼプレーンテキストエディタで、HTMLを使ってエディタに挿入するデータを作るよりもプレーンテキストを使った方が安定して自然な結果が得られました。

ペーストデータがHTMLから作られるときに困った例をあげると、ペーストする内容に<br>タグがあるときにエディタのスキーマに<br>に対応するルールがないためそれが無視されてしまい、ペースト後のエディタ上のテキストでは<br>だったところに改行されずひとつづきのテキストになるというものがありました。

例外として、今回作ったエディタ自体からコピーしたコンテンツをペーストしたときはHTML形式のデータを使っています。この場合にプレーンテキストの方を使うと、コピーされたテキストに含まれるメンション部分がペースト時に”@xxx”という通常の文字列になってしまいます。またコピー元が同じエディタなので、このときのHTMLにはエディタのデータとして復元できる内容しか含まれていません。そのため一般のHTMLデータを使ったときに発生しうる問題が起きることもありません。

データを保存する・復元する

エディタを使って編集したテキストはJavaScriptのオブジェクトとして保持されています。その内容をサーバーに保存する時には、エディタからドキュメントのデータを取り出す必要があります。

JSONを使う

エディタが保持しているドキュメントはNodeオブジェクトのツリーです。これには子ノードを操作するための中間的なオブジェクトなど、ドキュメントの内容以外のものを含んでいます。Node.toJSON()メソッドを使うと、純粋にドキュメントの情報だけを含んだオブジェクト取り出すことができます。

ドキュメント全体の情報はstate.doc.toJSON()で取り出せます。取り出したオブジェクトをどこかに保存しておいてあとで復元するときはNode.fromJSON()でNodeオブジェクトに戻すことができます。

const doc = Node.fromJSON(schema, JSON.parse(content))
const state = EditorState.create({ doc })

独自の形式を使う

JSON以外の形でデータを保存する場合は、ドキュメントのノードのツリーをシリアライズする処理を自分で用意することになります。

Backlogのメンション対応エディタはワープロのようなWYSIWYGエディタではなくほぼプレーンテキストエディタなので、エディタの各行をそのまま書き出して改行文字で結合したものが出力になります。また、メンション部分は専用の記法に置き換えて書き出しています。

シリアライズされたドキュメントエディタに復元するには、docノードをルートとするツリーデータに復元する処理を自分で用意する必要があります。

const doc = myParser.parse(serializedDocument)
const state = new EditorState.create({ doc })

独自の形式にデータを取り出したり復元したりするときにはprosemirror-markdownパッケージのソースコードが参考になるかと思います。このパッケージではMarkdownを読み書きするエディタのスキーマ、パーサー、シリアライザが定義されています。

さいごに

Backlogのメンション対応版エディタで利用したProseMirrorについて、実際に使ってみたときの手触りのようなものを伝えられたらと思って書きました。今回扱った内容はとても単純なもので、本格的にリッチなエディタの例についてはProseMirrorの公式サイトをご覧いただければと思います。この記事がProseMirrorに興味を持つきっかけや理解の助けになったとしたら幸いです。

 

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる