Developersをフォローする

【ProseMirror】React(Next.js)で簡単にWYSIWYGマークダウンエディタを作成してみた

フロントエンド

業務でProseMirrorを使用してマークダウン入力可能なWYSIWYGエディタを実装する機会がありました。
業務ではVue.jsにProseMirrorを組み込んで実装したので、今回はReactを使用してマークダウン入力可能なWYSIWYGエディタを実装していきたいと思います。

※ 今回は簡単にセットアップすることをメインに紹介したいので、細かいライブラリの拡張等は省略いたします。(需要があれば別記事にするかもです。)

ProseMirrorとは

詳しくは公式ドキュメントをじっくりと見ることをお勧めしますが、個人的に重要な概念として下記を抑えておくと実装に入りやすいと感じています。

公式のドキュメントに記載されている

The core library is not an easy drop-in component—we are prioritizing modularity and customizability over simplicity, with the hope that, in the future, people will distribute drop-in editors based on ProseMirror. As such, this is more of a Lego set than a Matchbox car.

将来、ProseMirrorをベースにしたドロップインエディタを配布する人が出てくることを期待して、シンプルさよりもモジュール性とカスタマイズ性を優先しているのです。そのため、これはマッチ箱の車というよりはレゴのセットのようなものです。
↑ DeepLによる翻訳

つまり、ProseMirrorは一つのパッケージで完結するのではなく、いくつかのモジュール(パッケージ)を組み合わせてエディタを実装するということを頭に入れておくとProseMirrorの理解が深まると思います。

使用したモジュール(パッケージ)たち

ProseMirrorでエディタを実装する際に基本的に必要になるライブラリに加え、今回はマークダウン入力も可能にするのでマークダウン入力に必要なモジュールを使用しています。

多い。。。と感じた方もいるかもしれませんが、先ほどProseMirrorの紹介で説明した通りそれぞれのモジュールを組み合わせて一つのエディタを作り上げていくためインストールするものも多めになっています。
また、今回使用したモジュール以外にもいくつかモジュールが存在し、どれをインストールすればよいかわからなくなる方もいるかもしれません。(自分はそうでした。)
そんな時は、prosemirror-example-setup を参考にしてあげると良いと思います。

prosemirror-example-setupは何者かというと、特に難しいことを考えずにエディタを実装することができてしまうモジュールです!
ただし、デフォルトの設定になるので機能を拡張したい場合には少し不向きかなと感じる部分があるので、ライブラリのソースを参考に実装を進める形が今後のことを考える場合にはベターかなと思います。

ちなみに今回実装したものは、ほぼprosemirror-example-setupを参考に実装しています。

セットアップ方法 

本格的なセットアップの前にまずスキーマと呼ばれる構造を用意する必要があります。
今回のスキーマはこちらです。

TypeScript
// eslint-disable-next-line import/no-extraneous-dependencies
import { Schema } from 'prosemirror-model';

export const schema = new Schema({
  nodes: {
    doc: {
      content: 'block+',
    },

    paragraph: {
      content: 'inline*',
      group: 'block',
      parseDOM: [{ tag: 'p' }],
      toDOM() { return ['p', 0]; },
    },

    blockquote: {
      content: 'block+',
      group: 'block',
      parseDOM: [{ tag: 'blockquote' }],
      toDOM() { return ['blockquote', 0]; },
    },

    horizontal_rule: {
      group: 'block',
      parseDOM: [{ tag: 'hr' }],
      toDOM() { return ['div', ['hr']]; },
    },

    ordered_list: {
      content: 'list_item+',
      group: 'block',
      attrs: { order: { default: 1 }, tight: { default: false } },
      parseDOM: [{
        tag: 'ol',
        getAttrs(dom) {
          return {
            order: (dom as HTMLElement).hasAttribute('start') ? +(dom as HTMLElement).getAttribute('start')! : 1,
            tight: (dom as HTMLElement).hasAttribute('data-tight'),
          };
        },
      }],
      toDOM(node) {
        return ['ol', {
          start: node.attrs.order === 1 ? null : node.attrs.order,
          'data-tight': node.attrs.tight ? 'true' : null,
        }, 0];
      },
    },

    bullet_list: {
      content: 'list_item+',
      group: 'block',
      attrs: { tight: { default: false } },
      parseDOM: [{ tag: 'ul', getAttrs: (dom) => ({ tight: (dom as HTMLElement).hasAttribute('data-tight') }) }],
      toDOM(node) { return ['ul', { 'data-tight': node.attrs.tight ? 'true' : null }, 0]; },
    },

    list_item: {
      content: 'paragraph block*',
      defining: true,
      parseDOM: [{ tag: 'li' }],
      toDOM() { return ['li', 0]; },
    },

    text: {
      group: 'inline',
    },

    hard_break: {
      inline: true,
      group: 'inline',
      selectable: false,
      parseDOM: [{ tag: 'br' }],
      toDOM() { return ['br']; },
    },
  },

  marks: {
    em: {
      parseDOM: [{ tag: 'i' }, { tag: 'em' },
        { style: 'font-style', getAttrs: (value) => value === 'italic' && null }],
      toDOM() { return ['em']; },
    },

    strong: {
      parseDOM: [{ tag: 'b' }, { tag: 'strong' },
        { style: 'font-weight', getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null }],
      toDOM() { return ['strong']; },
    },

    link: {
      attrs: {
        href: {},
        title: { default: null },
        target: { default: '_blank' },
      },
      inclusive: false,
      parseDOM: [{
        tag: 'a[href]',
        getAttrs(dom) {
          return { href: (dom as HTMLElement).getAttribute('href'), title: (dom as HTMLElement).getAttribute('title') };
        },
      }],
      toDOM(node) { return ['a', node.attrs]; },
    },
  },
});

export default schema;

スキーマの定義はprosemirror-markdownに定義されているschema.tsとほぼ同様の定義になっています。
https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/schema.ts

スキーマの定義がもととなって表示される内容が決まっていきます。(例えば、番号リストや箇条書きリスト、太字、斜体など)

続いてエディタのセットアップです。
prosemirror-example-setupを参考に実装したエディタコンポーネントのソースです。

React
/* eslint-disable import/no-extraneous-dependencies */
import { FC, useEffect, useRef } from 'react';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap } from 'prosemirror-commands';
import { menuBar } from 'prosemirror-menu';
import { history } from 'prosemirror-history';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import 'prosemirror-menu/style/menu.css';
import 'prosemirror-view/style/prosemirror.css';
// eslint-disable-next-line import/no-named-as-default
import schema from '@/const/Editor/schema';
import { buildInputRules } from '@/const/Editor/inputrules';
import { buildMenuItems } from '@/const/Editor/menu';
import { buildKeymap } from '@/const/Editor/keymap';
import { parser } from '@/const/Editor/parser';
import style from './index.module.css';

type Props = {
  editorValue: string
  setEditorValue: (value: string, index: number) => void
  isEdit: boolean
  index: number
};

const Editor: FC<Props> = ({
  editorValue,
  setEditorValue,
  isEdit,
  index,
}) => {
  const contentRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const editorState = EditorState.create({
      doc: parser.parse(editorValue) ?? undefined,
      plugins: isEdit
        ? [
          keymap(buildKeymap(schema)),
          keymap(baseKeymap),
          buildInputRules(schema),
          menuBar({
            content: buildMenuItems(schema).fullMenu,
            floating: true,
          }),
          history(),
        ] : undefined,
    });
    const view = new EditorView(contentRef.current, {
      state: editorState,
      dispatchTransaction(tr) {
        const newState = view.state.apply(tr);
        view.updateState(newState);
        setEditorValue(defaultMarkdownSerializer.serialize(newState.doc), index);
      },
      editable: () => isEdit,
    });

    view.focus();
    return () => { view.destroy(); };
    // NOTE: 第2引数を指定した場合にstylesheet関連でエラーが出るためeditorValueは含めない
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isEdit]);

  return (
    <div ref={contentRef} className={style.editor} />
  );
};

export default Editor;

それぞれの行にて下記のことをやってくれています。

36行目から49行目でProseMirrorで使用するステート(エディタの情報みたいなもの)を作成しています。
38行目から48行目のpluginsの箇所では、様々なモジュールの機能を設定しています。
簡単にですが、それぞれの設定している内容になります。

  • 40行目、41行目のkeymapの箇所ではキーボードの入力に対してどのような処理を行うかを設定
  • 42行目のbuildInputRulesでは入力された値によってどのように変換するかを設定
  • 43行目のmenuBarでは、名前の通りですがエディタに表示される装飾メニューを設定
  • 47行目のhistoryでは、入力内容を履歴として保持する設定

それぞれインポート先は異なりいくつかのモジュール等の機能を利用してエディタの基本的な情報を作成していきます。

pluginsの中で使用しているメソッド(buildKeymapやbuildInputRules、buildMenuItems)等はモジュールからインポートしているわけではありませんが、これらもprosemirror-example-setup内のソースを参考に、今回のスキーマに合わせて適宜修正したメソッドたちになります。

ソースの詳細は該当のメソッドのリンクからご覧ください。

buildKeymap: https://github.com/ryu-0729/baseball-note-demo/blob/master/src/const/Editor/keymap.ts
buildInputRules: https://github.com/ryu-0729/baseball-note-demo/blob/master/src/const/Editor/inputrules.ts
buildMenuItems: https://github.com/ryu-0729/baseball-note-demo/blob/master/src/const/Editor/menu.ts

続いて50行目から58行目で先ほど設定したエディタの情報をもとに、実際にDOM表示する内容を作成しています。
52行目から56行目ではエディタの状態を更新する処理を行っています。

今回はマークダウンの入力を可能にしているので55行目にて、入力された情報をもとにマークダウン形式のテキストに変換しています。
この変換処理はprosemirror-markdownのモジュールの機能を使用しています。

また、57行目ではエディタを編集することができる状態かを制限しています。

設定方法は以上になりますが、いくつかのモジュールを組み合わせてエディタを組み立てる意味がなんとなく理解いただけたら嬉しく思います。
繰り返しにはなりますが、上記の設定方法はprosemirror-example-setupを参考に実装したものになるのでソースを読むことができてば、比較的簡単にセットアップできるかと感じています。

拡張した機能

本記事では説明を省略しますが、業務上で拡張した機能をいくつか紹介させていただきます。

  • カスタムマークダウン入力
  • リンクのマークダウンを変更しました。
  • プレースホルダーの追加
  • マークダウン入力の追加
  • 取り消し線の追加しました。

良い点、難しい点(個人的な意見)

良い点

良い点は、ドキュメントや参考ソースなどが充実しているので実装はやりやすかったと感じています。
なので基本的なエディタの実装は簡単にできると感じました。

難しい点

難しい点は、マークダウンの入力をデフォルトの設定ではなく別のマークの入力にしたい場合やマークを追加する場合には、ProseMirrorの拡張とマークダウンのパーサーで使用されているmarkdown-itというライブラリでプラグインを実装する必要がありました。
markdown-itのプラグインのドキュメントはあまり優しくなく非常に大変と感じる部分ではありました。
既にプラグインが用意されている場合には問題ないと思いますが、自作する場合は難しい部分であると思います。

まとめ

今回は簡単にセットアップできるマークダウン入力可能なWYSIWYGエディタとしてProseMirrorを紹介させていただきました。
また、機能の拡張には難しい点も一部ありますが、拡張性にも優れていると個人的には感じています。
本記事がライブラリ選定や導入のきっかけになりましたら幸いです。
ありがとうございました。