👻

ReactでProseMirrorを使ったWYSIWYGエディタの実装

2023/01/08に公開

始めに

WYSIWYGエディタライブラリの一つにProseMirrorがありますが、これはReactやVue.jsを使わないpureなJavaScriptであるため、ProseMirror単体でWYSIWYGエディタを作ることができます。しかし、エディタをカスタマイズするに当たってフォントサイズの変更などのメニューを用意したい場合にUIの実装はReactなどを使った方が作りやすくはあります。そこで今回はReactでProseMirrorを使う方法を先に説明し、その後実際にいくつか機能を盛り込んだWYSIWYGエディタを紹介したいと思います。

実際に作ったもの

今回は以下の装飾機能を持つWYSIWYGエディタを作りました。

  • 太字、斜体、下線の装飾
  • 色変更
  • 文字サイズ変更
  • テキストの位置指定(左寄せ、中央寄せ、右寄せ)
  • テキストリンク設定

ProseMirrorで動かした内容をReactと連動させる

まず最初にProseMirrorで入力した内容をReactと連動できるようにします。Reactで操作しやすいようにEditorコンポーネントでラップさせます。
EditorコンポーネントのinterfaceはinitialHtmlonChangeHtmlだけで、初期表示用のHTMLテキストを使って初期表示をし、後は入力変更があるたびにonChangeHtmlでHTMLテキストを親コンポーネントに送ります。
ProseMirrorの起動はあらかじめrefで参照しておいたDOMに対してマウントします。ProseMirrorのstateにはinitialHtmlをDocに変換したものを使って、初期のテキストを構成します。ProseMirrorで変更があったときはdispatchTransactionコールバックで変更処理を受け取れるので、その時に現在のinnerHTMLをonChangeHtmlに送ります。
これでReactとの連携自体は完了しましたが、今後EditorViewのステータスを見てUIを変更する際にこのままだとReactのrerenderが走らないためUIの変更が描画されません。そこでforceUpdateの役割を果たす仮のreducerを用意し、それをProseMirror変更時に実行することでUI変更の追従ができるようにします。

Editor.tsx
import { useEffect, useRef, useReducer, FC } from "react";
import { Schema, Node, DOMParser } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { undo, redo, history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap, toggleMark } from "prosemirror-commands";
import { schema } from "../schema";

const createDoc = <T extends Schema>(html: string, pmSchema: T) => {
  const element = document.createElement("div");
  element.innerHTML = html;

  return DOMParser.fromSchema(pmSchema).parse(element);
};

const createPmState = <T extends Schema>(
  pmSchema: T,
  options: { doc?: Node } = {}
) => {
  return EditorState.create({
    doc: options.doc,
    schema: pmSchema,
    plugins: [
      history(),
      keymap({
        "Mod-z": undo,
        "Mod-y": redo,
        "Mod-Shift-z": redo
      }),
      keymap({
        Enter: baseKeymap["Enter"],
        Backspace: baseKeymap["Backspace"]
      })
    ]
  });
};

export type Props = {
  initialHtml: string;
  onChangeHtml: (html: string) => void;
};

export const Editor: FC<Props> = (props) => {
  const [_, forceUpdate] = useReducer((x) => x + 1, 0);
  const elContentRef = useRef<HTMLDivElement | null>(null);
  const editorViewRef = useRef<EditorView>();

  useEffect(() => {
    const doc = createDoc(props.initialHtml, schema);
    const state = createPmState(schema, { doc });

    const editorView = new EditorView(elContentRef.current, {
      state,
      dispatchTransaction(transaction) {
        const newState = editorView.state.apply(transaction);
        editorView.updateState(newState);

        props.onChangeHtml(editorView.dom.innerHTML);
        forceUpdate();
      }
    });
    editorViewRef.current = editorView;
    forceUpdate();

    return () => {
      editorView.destroy();
    };
  }, []);

  return (
    <div>
      <div ref={elContentRef} />
    </div>
  );
};

ProseMirrorのschemaの定義は現段階だと以下のようになっています。

schema.ts
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];
      }
    },
    text: {
      group: "inline"
    }
  },
  marks: {}
});

後はこのEditor.tsxをアプリケーションで呼び出します。入力内容を確認できるようにHTMLテキストやプレビューも出せるようにします。

App.tsx
import { FC, useState } from "react";
import { Editor } from "./components/Editor";

const INITIAL_HTML = `<p>Reactと連携</p>`;

const App: FC = () => {
  const [html, setHtml] = useState(INITIAL_HTML);

  return (
    <div>
      <Editor
        initialHtml={html}
        onChangeHtml={(newHtml) => {
          setHtml(newHtml);
        }}
      />
      <div>
        <h3>HTML</h3>
        <div>{html}</div>
      </div>
      <div className="preview">
        <h3>プレビュー</h3>
        <div
          dangerouslySetInnerHTML={{
            __html: html
          }}
        />
      </div>
    </div>
  );
};

export default App;

これを実行すると以下のような動作になります。

各装飾の実装

ProseMirrorをReactと連携できたので、ここからは各装飾の実装に入っていきます。ProseMirrorへ操作を実行する場合はCommandを発行してそれを実行するフローになっています。そのCommandをReactで作ったUIコンポーネント内で呼び出せば良いので、実装の流れは基本以下のようになります。

  • 装飾用のProseMirror Schemaの定義
  • 装飾を行うCommandを定義
  • React UI上でCommandを実行

太字、斜体、下線の装飾

schemaの定義

まず最初に太字、斜体、下線のschemaを定義します。コードは以下のようになります。

太字、斜体、下線のschema
import { Schema } from "prosemirror-model";

export const schema = new Schema({
  // nodesは省略
  marks: {
    /// An emphasis mark. Rendered as an `<em>` element. Has parse rules
    /// that also match `<i>` and `font-style: italic`.
    em: {
      parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }],
      toDOM() {
        return ["em", 0];
      }
    },
    /// A strong mark. Rendered as `<strong>`, parse rules also match
    /// `<b>` and `font-weight: bold`.
    strong: {
      parseDOM: [
        { tag: "strong" },
        // This works around a Google Docs misbehavior where
        // pasted content will be inexplicably wrapped in `<b>`
        // tags with a font-weight normal.
        {
          tag: "b",
          getAttrs: (node: string | HTMLElement) =>
            typeof node !== "string" &&
            node.style.fontWeight !== "normal" &&
            null
        },
        {
          style: "font-weight",
          getAttrs: (value: string | HTMLElement) =>
            typeof value === "string" &&
            /^(bold(er)?|[5-9]\d{2,})$/.test(value) &&
            null
        }
      ],
      toDOM() {
        return ["strong", 0];
      }
    },
    underline: {
      parseDOM: [{ tag: "u" }],
      toDOM() {
        return ["u", 0];
      }
    },
  }
});

ちなみに太字、斜体については以下のコードを参考にしました。

https://github.com/ProseMirror/prosemirror-schema-basic/blob/1.2.0/src/schema-basic.ts

装飾で行うCommandの定義

範囲選択したテキストにmarkをつけたり外したりするCommandは既にtoggleMarkという名前でProseMirrorに提供されており、今回はこちらを使います。

https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L498-L538

React UI上でCommandを実行

最後にこのCommandをReact UI上で実行します。そこでまずは装飾用のメニューコンポーネントを作ります。今選択しているテキストが装飾がついているかもUIで見えていた方が良いため、isActiveMarkというutilメソッドを用意して、チェックしています。

EditorMenu.tsx
import { FC } from "react";
import { EditorView } from "prosemirror-view";
import { toggleMark } from "prosemirror-commands";
import { schema } from "../../schema";
import { isActiveMark } from "./isActiveMark";

export type EditorMenuProps = {
  editorView: EditorView;
};

export const EditorMenu: FC<EditorMenuProps> = (props) => {
  return (
    <div>
      <button
        style={{
          fontWeight: isActiveMark(props.editorView.state, schema.marks.strong)
            ? "bold"
            : undefined
        }}
        onClick={() => {
          toggleMark(schema.marks.strong)(
            props.editorView.state,
            props.editorView.dispatch,
            props.editorView
          );
          props.editorView.focus();
        }}
      >
        B
      </button>
      <button
        style={{
          fontWeight: isActiveMark(props.editorView.state, schema.marks.em)
            ? "bold"
            : undefined
        }}
        onClick={() => {
          toggleMark(schema.marks.em)(
            props.editorView.state,
            props.editorView.dispatch,
            props.editorView
          );
          props.editorView.focus();
        }}
      >
        I
      </button>
      <button
        style={{
          fontWeight: isActiveMark(
            props.editorView.state,
            schema.marks.underline
          )
            ? "bold"
            : undefined
        }}
        onClick={() => {
          toggleMark(schema.marks.underline)(
            props.editorView.state,
            props.editorView.dispatch,
            props.editorView
          );
          props.editorView.focus();
        }}
      >
        U
      </button>
    </div>
  );
};

isActiveMarkの実装はtoggleMarkでaddするかremoveするかの判定をしているコードを抜粋したものになっています。

isActiveMark.ts
import { EditorState } from "prosemirror-state";
import { TextSelection } from "prosemirror-state";
import { MarkType } from "prosemirror-model";

/**
 * 選択中のエディタが既にmarkの設定をしているかチェック
 * @param state - エディタのステート
 * @param markType - アクティブか調べるMark
 */
export const isActiveMark = (state: EditorState, markType: MarkType) => {
  // 参考: https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L510-L534
  if (state.selection instanceof TextSelection && state.selection.$cursor) {
    return markType.isInSet(
      state.storedMarks || state.selection.$cursor.marks()
    )
      ? true
      : false;
  }

  const { ranges } = state.selection;
  for (let i = 0; i < ranges.length; i++) {
    if (
      state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, markType)
    ) {
      return true;
    }
  }
  return false;
};

このメニューコンポーネントをEditor.tsxで呼び出したら完成です。

Editor.tsx
 // 省略

 export const Editor: FC<Props> = (props) => {
   // 省略

   return (
     <div>
+      {editorViewRef.current && (
+        <EditorMenu editorView={editorViewRef.current} />
+      )}
       <div ref={elContentRef} style={{ marginTop: "4px" }} />
     </div>
   );
 };

実行すると以下のような動きになると思います。

余談: ショートカットキーの設定

ProseMirrorのCommandの実行はReact UIからに限ったものではなく、ショートカットキーからでも実行できます。むしろそっちに合わせたインターフェースな気がしています。
これはprosemirror-keymapモジュールに目的のキーに対して実行したいCommandをセットするだけで動きます。

ショートカットキーの設定
import { keymap } from "prosemirror-keymap";
import { baseKeymap, toggleMark } from "prosemirror-commands";

const createPmState = <T extends Schema>(
  pmSchema: T,
  options: { doc?: Node } = {}
) => {
  return EditorState.create({
    doc: options.doc,
    schema: pmSchema,
    plugins: [
      // 他pluginは省略
      keymap({
        "Mod-b": toggleMark(pmSchema.marks.strong),
        "Mod-i": toggleMark(pmSchema.marks.em),
        "Mod-u": toggleMark(pmSchema.marks.underline)
      }),
    ]
  });
};

色変更

schemaの定義

続いて色のschemaを定義します。色はタグで表現することができないため、spanタグにstyle属性を付与するschemaにします。

色のschema定義
import { Schema } from "prosemirror-model";

export const schema = new Schema({
  // nodesは省略
  marks: {
    // 他のmarksは省略
    color: {
      attrs: {
        color: {}
      },
      parseDOM: [
        {
          tag: "span",
          getAttrs: (dom: string | HTMLElement) => {
            if (typeof dom === "string") {
              return false;
            }
            const { color } = dom.style;
            if (!color) {
              return false;
            }
            return {
              color
            };
          }
        }
      ],
      toDOM(mark) {
        const { color } = mark.attrs;
        return ["span", { style: `color: ${color}` }, 0];
      }
    },
  }
});

装飾で行うCommandの定義

色の装飾は指定した色をセットする形になるため、太字などの装飾と違ってtoggleMarkをすることができません。そこでこのコードを参考にaddMarkremoveMarkに分けて、それぞれつけたり外したりできるCommandを用意します。

addMark.ts
import { MarkType, Attrs } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";

/**
 * Markをセットするコマンド
 * toggleMarkからadd機能だけ抽出
 * https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L505-L538
 * @param markType - Markタイプ
 * @param attrs - Markに付与する属性
 */
export const addMark = (
  markType: MarkType,
  attrs: Attrs | null = null
): Command => {
  return (state, dispatch) => {
    let { empty, $cursor, ranges } = state.selection as TextSelection;
    if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
      return false;

    if (dispatch) {
      if ($cursor) {
        dispatch(state.tr.addStoredMark(markType.create(attrs)));
      } else {
        let tr = state.tr;
        for (let i = 0; i < ranges.length; i++) {
          const { $from, $to } = ranges[i];
          let from = $from.pos,
            to = $to.pos,
            start = $from.nodeAfter,
            end = $to.nodeBefore;
          let spaceStart =
            start && start.isText ? /^\s*/.exec(start.text!)![0].length : 0;
          let spaceEnd =
            end && end.isText ? /\s*$/.exec(end.text!)![0].length : 0;
          if (from + spaceStart < to) {
            from += spaceStart;
            to -= spaceEnd;
          }
          tr = tr.addMark(from, to, markType.create(attrs));
        }
        dispatch(tr.scrollIntoView());
      }
    }
    return true;
  };
};
removeMark.ts
import { MarkType } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";

/**
 * Markを取り除くコマンド
 * toggleMarkからremove機能だけ抽出
 * https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L505-L538
 * @param markType - Markタイプ
 * @param attrs - Markに付与する属性
 */
export const removeMark = (markType: MarkType): Command => {
  return (state, dispatch) => {
    const { empty, $cursor, ranges } = state.selection as TextSelection;
    if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
      return false;

    if (dispatch) {
      if ($cursor) {
        dispatch(state.tr.removeStoredMark(markType));
      } else {
        let tr = state.tr;
        for (let i = 0; i < ranges.length; i++) {
          const { $from, $to } = ranges[i];
          tr = tr.removeMark($from.pos, $to.pos, markType);
        }
        dispatch(tr.scrollIntoView());
      }
    }
    return true;
  };
};

React UI上でCommandを実行

このCommandをReact UI上で実行します。EditorMenu.tsxにそのまま書くとコードが膨らんでしまうので、更にMenuItemColor.tsxでコンポーネント化し、onSetColoronResetColorを呼びます。メニュー上で何色を選択しているかが分かる様に、getSelectionMarkutilメソッドでmarkのattributesを取り出しています。

MenuItemColor.tsx
import { FC, useState, useMemo } from "react";
import { useDetectClickOutside } from "react-detect-click-outside";
import { EditorState } from "prosemirror-state";
import { getSelectionMark } from "../getSelectionMark";
import { schema } from "../../../schema";

export type MenuItemColorProps = {
  editorState: EditorState;
  onSetColor: (color: string) => void;
  onResetColor: () => void;
};

const COLOR_LIST = ["red", "orange", "green", "cyan", "blue", "purple"];

export const MenuItemColor: FC<MenuItemColorProps> = (props) => {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useDetectClickOutside({
    onTriggered: () => {
      setIsOpen(false);
    }
  });

  const selectedColor = useMemo(() => {
    const mark = getSelectionMark(props.editorState, schema.marks.color);
    return mark ? mark.attrs.color : "black";
  }, [props.editorState]);

  return (
    <div ref={ref} style={{ display: "inline-block", position: "relative" }}>
      <button
        onClick={() => {
          setIsOpen(true);
        }}
      >
        <span
          style={{
            display: "inline-block",
            width: "10px",
            height: "10px",
            backgroundColor: selectedColor
          }}
        />
      </button>
      {isOpen && (
        <div
          style={{
            position: "absolute",
            zIndex: 10,
            padding: "10px",
            border: "solid 1px #ccc",
            backgroundColor: "white"
          }}
        >
          <div
            style={{
              display: "grid",
              gridTemplateColumns: "1fr 1fr",
              gridGap: "4px"
            }}
          >
            {COLOR_LIST.map((COLOR) => (
              <button
                key={COLOR}
                onClick={() => {
                  props.onSetColor(COLOR);
                  setIsOpen(false);
                }}
              >
                {COLOR}
              </button>
            ))}
          </div>
          <hr />
          <div style={{ textAlign: "right" }}>
            <button
              onClick={() => {
                props.onResetColor();
                setIsOpen(false);
              }}
            >
              reset
            </button>
          </div>
        </div>
      )}
    </div>
  );
};

getSelectionMark.tsの実装は以下になります。

getSelectionMark.ts
import { MarkType } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";

/**
 * 選択中のエディタの先頭カーソル部分のmarkデータを取得
 * @param state - エディタのステート
 * @param markType - 取得するMark
 */
export const getSelectionMark = (state: EditorState, markType: MarkType) => {
  // 参考: https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L510-L534
  if (state.selection instanceof TextSelection && state.selection.$cursor) {
    return markType.isInSet(
      state.storedMarks || state.selection.$cursor.marks()
    );
  }

  const { $from } = state.selection;
  return $from.nodeAfter ? markType.isInSet($from.nodeAfter.marks) : undefined;
};

後はこれをEditorMenu.tsxの方に追記して完成です。

EditorMenu.tsx
 export const EditorMenu: FC<EditorMenuProps> = (props) => {
   return (
     <div>
       {/* 他のメニューボタンは省略 */}
+      <MenuItemColor
+        editorState={props.editorView.state}
+        onSetColor={(color) => {
+          addMark(schema.marks.color, { color })(
+            props.editorView.state,
+            props.editorView.dispatch,
+            props.editorView
+          );
+          props.editorView.focus();
+        }}
+        onResetColor={() => {
+          removeMark(schema.marks.color)(
+            props.editorView.state,
+            props.editorView.dispatch,
+            props.editorView
+          );
+          props.editorView.focus();
+        }}
+      />
     </div>
   );
 };

実行すると以下のような動きになります。

文字サイズ変更

schemaの定義

次は文字サイズのschemaを定義します。色と同じようにタグでは表現できないので、spanタグにstyleを当てる感じにします。

文字サイズのschema
import { Schema } from "prosemirror-model";

export const schema = new Schema({
  // nodesは省略
  marks: {
    // 他のmarksは省略
    size: {
      attrs: {
        fontSize: {}
      },
      parseDOM: [
        {
          tag: "span",
          getAttrs: (dom: string | HTMLElement) => {
            if (typeof dom === "string") {
              return false;
            }
            const { fontSize } = dom.style;
            if (!fontSize) {
              return false;
            }
            return {
              fontSize
            };
          }
        }
      ],
      toDOM(mark) {
        const { fontSize } = mark.attrs;
        return ["span", { style: `font-size: ${fontSize}` }, 0];
      }
    }
  }
});

装飾で行うCommandの定義

色変更と同じやり方になるのでaddMarkremoveMarkを使いまわします。

React UI上でCommandを実行

色変更と同じようにコンポーネントを分けて、フォントサイズ変更のUIコンポーネントをまずは用意します。

MenuItemFontSize.tsx
import { FC, useMemo } from "react";
import { EditorState } from "prosemirror-state";
import { getSelectionMark } from "../getSelectionMark";
import { schema } from "../../../schema";

export type MenuItemFontSizeProps = {
  editorState: EditorState;
  onSetFontSize: (fontSize: string) => void;
  onResetFontSize: () => void;
};

const FONT_SIZE_LIST = ["12px", "16px", "24px"];

export const MenuItemFontSize: FC<MenuItemFontSizeProps> = (props) => {
  const selectedFontSize = useMemo(() => {
    const mark = getSelectionMark(props.editorState, schema.marks.size);
    return mark ? mark.attrs.fontSize : "16px";
  }, [props.editorState]);
  return (
    <select
      value={selectedFontSize}
      onChange={(event) => {
        const fontSize = event.currentTarget.value;
        if (fontSize === "16px") {
          props.onResetFontSize();
        } else {
          props.onSetFontSize(fontSize);
        }
      }}
    >
      {FONT_SIZE_LIST.map((fontSize) => (
        <option key={fontSize} value={fontSize}>
          {fontSize}
        </option>
      ))}
    </select>
  );
};

これをEditorMenu.tsxに追加して完成です。

EditorMenu.tsx
 export const EditorMenu: FC<EditorMenuProps> = (props) => {
   return (
     <div>
       {/* 他のメニューボタンは省略 */}
+      <MenuItemFontSize
+        editorState={props.editorView.state}
+        onSetFontSize={(fontSize) => {
+          addMark(schema.marks.size, { fontSize })(
+            props.editorView.state,
+            props.editorView.dispatch,
+            props.editorView
+          );
+          props.editorView.focus();
+        }}
+        onResetFontSize={() => {
+          removeMark(schema.marks.size)(
+            props.editorView.state,
+            props.editorView.dispatch,
+            props.editorView
+          );
+          props.editorView.focus();
+        }}
+      />
     </div>
   );
 };

実行すると以下の様な動きになります。

テキストの位置指定(左寄せ、中央寄せ、右寄せ)

schemaの定義

次はテキストの位置指定のschemaを定義します。テキストの位置はparagraph Nodeで行った方が良いので、既存のnodeの定義にattrsを追加します。

テキストの位置指定のschema定義
 import { Schema } from "prosemirror-model";

 export const schema = new Schema({
   nodes: {
     // 他のnodesは省略
     paragraph: {
       content: "inline*",
       group: "block",
+      attrs: {
+        align: { default: "left" }
+      },
       parseDOM: [
         {
           tag: "p",
+          getAttrs(dom) {
+            if (typeof dom === "string") {
+              return false;
+            }
+            return {
+              align: dom.style.textAlign || "left"
+            };
+          }
         }
       ],
       toDOM(node) {
+        const { align } = node.attrs;
+        if (!align || align === "left") {
+          return ["p", 0];
+        }
+        return ["p", { style: `text-align: ${align}` }, 0];
       }
     },
   },
   // marksは省略
 }

装飾で行うCommandの定義

ProseMirror Nodeにattrsを変更するCommandは既にsetBlockTypeという名前で提供されているため、これを使うことにします。

https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L463-L483

React UI上でCommandを実行

後はこのCommandをReact UI上で実行します。ここでもテキスト位置設定用のコンポーネントに分けて実装します。カーソル位置のテキスト位置設定状況を知るためにgetSelectionNodeというutilメソッドを作っています。

MenuItemTextAlign.tsx
import { FC, useMemo } from "react";
import { EditorState } from "prosemirror-state";
import { getSelectionNode } from "../getSelectionNode";
import { schema } from "../../../schema";

export type MenuItemTextAlignProps = {
  editorState: EditorState;
  onSetTextAlign: (textAlign: string) => void;
};

const ALIGN_LIST = ["left", "center", "right"];

export const MenuItemTextAlign: FC<MenuItemTextAlignProps> = (props) => {
  const align = useMemo(() => {
    const node = getSelectionNode(props.editorState, schema.nodes.paragraph);
    return node ? node.attrs.align : "left";
  }, [props.editorState]);

  return (
    <select
      value={align}
      onChange={(event) => {
        props.onSetTextAlign(event.currentTarget.value);
      }}
    >
      {ALIGN_LIST.map((ALIGN) => (
        <option key={ALIGN} value={ALIGN}>
          {ALIGN}
        </option>
      ))}
    </select>
  );
};

getSelectionNode.tsの実装は以下の様になります。

getSelectionNode.ts
import { NodeType } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";

/**
 * エディタの先頭カーソル部分のNodeデータを取得
 * @param state - エディタのステート
 * @param nodeType - 取得するNode
 */
export const getSelectionNode = (state: EditorState, nodeType: NodeType) => {
  if (state.selection instanceof TextSelection && state.selection.$cursor) {
    const node = state.selection.$cursor.parent;
    if (node.type === nodeType) {
      return node;
    }
    return undefined;
  }

  const node = state.selection.$from.parent;
  return node.type === nodeType ? node : undefined;
};

後はEditorMenu.tsxにこれを追加して完成です。

EditorMenu.tsx
 export const EditorMenu: FC<EditorMenuProps> = (props) => {
   return (
     <div>
       {/* 他のメニューボタンは省略 */}
+      <MenuItemTextAlign
+        editorState={props.editorView.state}
+        onSetTextAlign={(align) => {
+          setBlockType(schema.nodes.paragraph, { align })(
+            props.editorView.state,
+            props.editorView.dispatch,
+            props.editorView
+          );
+          props.editorView.focus();
+        }}
+      />
     </div>
   );
 };

これを実行すると以下の様な動きになります。

テキストリンク設定

schemaの定義

最後にテキストリンクのschemaを定義します。

テキストリンクのschema
import { Schema } from "prosemirror-model";

export const schema = new Schema({
  // nodesは省略
  marks: {
    /// A link. Has `href` and `title` attributes. `title`
    /// defaults to the empty string. Rendered and parsed as an `<a>`
    /// element.
    link: {
      attrs: {
        href: {},
        title: { default: null }
      },
      // これがあると末尾で追加することができなくなる
      inclusive: false,
      parseDOM: [
        {
          tag: "a[href]",
          getAttrs(dom: string | HTMLElement) {
            if (typeof dom === "string") {
              return false;
            }
            return {
              href: dom.getAttribute("href"),
              title: dom.getAttribute("title")
            };
          }
        }
      ],
      toDOM(mark) {
        let { href, title } = mark.attrs;
        return ["a", { href, title, target: "_blank" }, 0];
      }
    }
    // 他のmarksは省略
  }
});

ちなみにlink markはこちらを参考にしました。

https://github.com/ProseMirror/prosemirror-schema-basic/blob/1.2.0/src/schema-basic.ts#L110-L123

装飾で行うCommandの定義

リンクを設定するCommandは既にaddMarkがあるのでそれを使いまわします。

React UI上でCommandを実行

他と同じようにReact UI上でCommandを実行します。コンポーネントは分けていますが、簡易実装でwindow.promptを使っているので大分スッキリしたコードになっています。

MenuItemLink.tsx
import { FC } from "react";
import { EditorState } from "prosemirror-state";

export type MenuItemLinkProps = {
  editorState: EditorState;
  onSetLink: (url: string) => void;
};

export const MenuItemLink: FC<MenuItemLinkProps> = (props) => {
  return (
    <button
      onClick={() => {
        const url = window.prompt("URL");
        if (url) {
          props.onSetLink(url);
        }
      }}
    >
      link
    </button>
  );
};

これをEditorMenu.tsxに追加したら完成です。

EditorMenu.tsx
 export const EditorMenu: FC<EditorMenuProps> = (props) => {
   return (
     <div>
       {/* 他のメニューボタンは省略 */}
+      <MenuItemLink
+        editorState={props.editorView.state}
+        onSetLink={(url) => {
+          addMark(schema.marks.link, { href: url })(
+            props.editorView.state,
+            props.editorView.dispatch,
+            props.editorView
+          );
+          props.editorView.focus();
+        }}
+      />
     </div>
   );
 };

実行すると以下の様な動きになります。

これでリンクの装飾はできましたが、なんのURLが設定されているかを確認する術が現状ではありません。次のセクションでURLの確認と変更ができるプラグインを作っていきます。

設定したリンクを確認・変更ができるプラグインの作成

リンクテキストにカーソルを当てた際に下にツールチップで設定されたURLの確認ができて、更に変更ができると良さそうなので、それを実装します。ツールチップの表示は以下でサンプルが公開されているので、それをベースに実装していきます。

https://prosemirror.net/examples/tooltip/

ツールチップ表示する機能をReactで実装する

プラグイン自体はProseMirror側のものですが、実際にrenderingする処理はReactで行って問題ないです。なのでその辺の橋渡しができる様にプラグイン内で調整します。PluginViewをインターフェースとしてクラスを作成し、constructorでReactの描画ができるようにcreateRootを実行します。ここで得られたreactRootをupdateのタイミングでreactRoot.renderを実行してReactの描画を実行し、詳細の実装はLinkTooltipコンポーネントに委ねています。

SelectionLinkTooltipView.tsx
import { createRoot } from "react-dom/client";
import { PluginView, EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { LinkTooltip } from "./LinkTooltip";

// 参考: https://prosemirror.net/examples/tooltip/
export class SelectionLinkTooltipView implements PluginView {
  private dom: HTMLElement;
  // 型が分からないので一旦any
  private reactRoot: any;

  constructor(view: EditorView) {
    this.dom = document.createElement("div");
    this.reactRoot = createRoot(this.dom);

    if (view.dom.parentNode) {
      view.dom.parentNode.appendChild(this.dom);
    }
  }

  update(view: EditorView, prevState: EditorState) {
    const state = view.state;
    // Don't do anything if the document/selection didn't change
    if (
      prevState &&
      prevState.doc.eq(state.doc) &&
      prevState.selection.eq(state.selection)
    ) {
      return;
    }

    if (this.dom.offsetParent == null) {
      return;
    }

    const box = this.dom.offsetParent.getBoundingClientRect();
    this.reactRoot.render(<LinkTooltip editorView={view} box={box} />);
  }

  destroy() {
    this.dom.remove();
    // 以下の警告が出てしまうのでワンサイクル置いてからunmountする
    // Attempted to synchronously unmount a root while React was already rendering.
    // React cannot finish unmounting the root until the current render has completed, which may lead to a race condition.
    // @see https://stackoverflow.com/questions/73459382/react-18-async-way-to-unmount-root
    setTimeout(() => {
      this.reactRoot.unmount();
    }, 0);
  }
}

このプラグインを更にラップすることでProseMirrorで使えるようになります。

const selectionLinkTooltipPlugin = () => {
  return new Plugin({
    view(editorView) {
      return new SelectionLinkTooltipView(editorView);
    }
  });
};

const createPmState = <T extends Schema>(
  pmSchema: T,
  options: { doc?: Node } = {}
) => {
  return EditorState.create({
    doc: options.doc,
    schema: pmSchema,
    plugins: [
      // 他のpluginは省略
      selectionLinkTooltipPlugin()
    ]
  });
};

ツールチップ内の実装(LinkTooltip.tsx)

実際にツールチップを描画するLinkTooltip.tsxのコードは以下の様になります。
まず表示する場所ですが、editorView.coordsAtPosで指定したテキスト位置の座標がどこかを取得できます。これとpropで受け取っている親DOMのサイズを照らし合わせて、適切な場所にabsoluteで配置します。後は現在のURLをeditorView.stateが変更されるたびに更新して、カーソル位置で設定されているURLを見れるようにします。もしそこにURLを設定しているmarkがない場合はツールチップ自体を非表示にします。
このURL自体を入力テキストにして、カーソルが移動しない間は自由にURLを変更して更新できるようにします。更新する際はこの後説明するupdateMark Commandを実行してURLを更新します。

LinkTooltip.tsx
import { FC, useState, useEffect } from "react";
import { clamp } from "lodash-es";
import { EditorView } from "prosemirror-view";
import { updateMark } from "../../commands";
import { getSelectionMark } from "../../components/EditorMenu/getSelectionMark";
import { schema } from "../../schema";

type Props = {
  editorView: EditorView;
  box: DOMRect;
};

const WIDTH = 255;
const OFFSET = 5;

export const LinkTooltip: FC<Props> = (props) => {
  const [url, setUrl] = useState("");

  const state = props.editorView.state;
  const { from, to } = state.selection;
  const start = props.editorView.coordsAtPos(from);
  const end = props.editorView.coordsAtPos(to);
  const center = Math.max((start.left + end.left) / 2, start.left + 3);
  const left = clamp(
    center - WIDTH / 2,
    props.box.left,
    props.box.right - WIDTH
  );

  useEffect(() => {
    const mark = getSelectionMark(props.editorView.state, schema.marks.link);
    if (mark) {
      setUrl(mark.attrs.href);
    }
  }, [props.editorView.state]);

  const mark = getSelectionMark(state, schema.marks.link);
  const isVisible = mark != null;

  return (
    <div
      style={{
        display: isVisible ? undefined : "none",
        position: "absolute",
        top: `${start.bottom - props.box.top + OFFSET}px`,
        left: `${left - props.box.left}px`,
        width: WIDTH,
        border: "solid 1px #ccc",
        padding: "5px",
        backgroundColor: "white",
        boxSizing: "border-box"
      }}
    >
      URL:
      <input
        type="text"
        value={url}
        onInput={(event) => {
          setUrl(event.currentTarget.value);
        }}
      />
      <button
        onClick={() => {
          updateMark(schema.marks.link, { href: url })(
            props.editorView.state,
            props.editorView.dispatch,
            props.editorView
          );
        }}
      >
        Apply
      </button>
    </div>
  );
};

URLを更新するCommandの用意

既存のmark装飾に対して新しいattrs(URL)を付与するCommandは用意されてないので、それを作ります。今までは選択した範囲に直接markを指定したり取り除いたりしていますが、URLを更新する場合は既に設定されているlink markの範囲を知る必要があるので、まず先にその範囲を取得してからその範囲に対してaddMarkします。今回の実装は簡易的にして、カーソルの前後のテキストブロックのみを対象にしています。

updateMark.ts
import { MarkType, Attrs } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";

/**
 * Markを更新するコマンド
 * @param markType - Markタイプ
 * @param attrs - Markに付与する属性
 */
export const updateMark = (
  markType: MarkType,
  attrs: Attrs | null = null
): Command => {
  return (state, dispatch) => {
    let { empty, $cursor, ranges } = state.selection as TextSelection;
    if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
      return false;

    if (dispatch) {
      const $pos = $cursor || state.selection.$from;
      let tr = state.tr;
      const { nodeBefore, nodeAfter } = $pos;

      // inline Nodeとかが含まれているとこれだけでは不十分そう
      if (nodeBefore && markType.isInSet(nodeBefore.marks)) {
        const textLength = nodeBefore.isText ? nodeBefore.text!.length : 0;
        tr = tr.addMark(
          $pos.pos - textLength,
          $pos.pos,
          markType.create(attrs)
        );
      }
      if (nodeAfter && markType.isInSet(nodeAfter.marks)) {
        const textLength = nodeAfter.isText ? nodeAfter.text!.length : 0;
        tr = tr.addMark(
          $pos.pos,
          $pos.pos + textLength,
          markType.create(attrs)
        );
      }
      dispatch(tr.scrollIntoView());
    }
    return true;
  };
};

これを実行すると以下のようになります。

終わりに

以上がReactでProseMirrorを使う方法でした。ProseMirror単体でも色々なことができますが、メニューのUIなどを作るときはReactを使った方が見た目の変更とかがやりやすくなると思うので、込み入った実装をしていくようになってきた際にこの記事が参考になれれば幸いです。

Discussion