ProseMirrorにAngularコンポーネントを埋め込んでみる

これは [Hubble Advent Calendar 2024](https://qiita.com/advent-calendar/2024/hubble) の11日目[^1]の記事です。

## はじめに
株式会社Hubbleのフロントエンドを担当している @moneyan9 です。

突然ですが、皆さんリッチテキストエディタにはどのようなものを使用していますか？
僕はこれまで [Quill](https://quilljs.com) を利用することが多かったのですが、Hubbleでは [ProseMirror](https://prosemirror.net) を利用しています。しかしながら、Hubbleに入社して9ヶ月…既に運用されているエディタ部分を触る機会はそれほど多くないため、ProseMirrorの理解を深める機会が少ないと感じていました。
そこで、今回はProseMirrorにAngularコンポーネントを埋め込むことを目的としながら、同時にProseMirrorの理解を深めていこうと思います。

## 参考
ProseMirrorの理解を進めるために役立ったリソースを紹介します。

- [公式ドキュメント](https://prosemirror.net/docs/ref)
  - ProseMirrorの各モジュールの詳細仕様やAPIリファレンスが記載されています
- [frederik/angular-elements-with-prosemirror](https://github.com/frederik/angular-elements-with-prosemirror)
  - Angular v6でProseMirrorとAngularコンポーネントを組み合わせる手法を解説したリポジトリです。現在のバージョンとは異なる箇所もありますが、全体像を理解する助けになりました

## ProseMirrorのモジュール
まずは、ProseMirrorの基本的なモジュール構成から見ていきます。
公式ドキュメントにも以下のように記載されているように、ProseMirrorは複数のモジュールで構成されているため、まずどのようなモジュールがあるのかを知る必要があります。
> The core library is not an easy drop-in component—we are prioritizing modularity and customizability over simplicity

まずは、コアとなる3つのモジュールについて理解します。
|モジュール|説明|
|:-|:-|
|prosemirror-model|ノードやマークといった文章の構造を定義する|
|prosemirror-state|ドキュメントの内容、選択範囲、トランザクションなどのエディタの状態の管理する|
|prosemirror-view|エディタの状態をブラウザ上に表示する|

上述の3つのモジュールによりProseMirrorを動作させることはできるのですが、これだけでは改行すらできなかったり、ノードやマークを自分で定義しないといけなかったり…と大変なので、更に利用頻度が高いと思われる3つのモジュールについても確認していきます。

|モジュール|説明|
|:-|:-|
|prosemirror-schema-basic|見出し、段落、リスト、引用などの一般的なノードや、太字、イタリック、リンクなどの一般的なマークを提供する|
|prosemirror-commands|一般的な編集操作（太字・リスト・ブロック変更など）を簡潔に実装するための関数を提供する|
|prosemirror-keymap|特定のキー入力に応じて、コマンドを実行するためのマッピングを管理する|

## 最小構成での実装
ざっくりと各モジュールの理解をしたところで、最小の構成でProseMirrorを動かしてみたいと思います（コード全体は[こちら](https://stackblitz.com/edit/angular-prosemirror-minimal?file=src%2Feditor.component.ts)）

1. 上述の6つのモジュールを package.json に追加
1. index.html で css をインポート
1. editor.component.ts を作成

```typescript:editor.component.ts
import { AfterViewInit, Component, ElementRef, viewChild } from '@angular/core';
import { baseKeymap } from 'prosemirror-commands';
import { keymap } from 'prosemirror-keymap';
import { Schema } from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';

@Component({
  selector: 'app-editor',
  standalone: true,
  template: `
    <div #editor style="border: solid 1px silver"></div>
    <h2>editorView.state.doc</h2>
    {{ editorView?.state?.doc }}
    <h2>editorView.dom.innerHTML</h2>
    {{ editorView?.dom?.innerHTML }}
  `,
})
export class EditorComponent implements AfterViewInit {
  private readonly editor =
    viewChild.required<ElementRef<HTMLDivElement>>('editor');

  editorView?: EditorView;

  ngAfterViewInit() {
    const mySchema = new Schema({
      nodes: schema.spec.nodes,
      marks: schema.spec.marks,
    });
    this.editorView = new EditorView(this.editor().nativeElement, {
      state: EditorState.create({
        schema: mySchema,
        plugins: [keymap(baseKeymap)],
      }),
    });
  }
}
```

Schemaのnodesやmarksで、きめ細やかな指定を行えるのがProseMirrorの特徴でもあるのですが、今回は `prosemirror-schema-basic` で用意されているスキーマ定義を利用しました。

一応、改行だけが可能なProseMirrorを動かすことができました。
![最小構成での実装結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130472/f9cb5713-74ee-1a71-3609-88af380550ac.png)

:::note warn
`keymap(baseKeymap)`によってバインドされるのはEnterキーやBackspaceキーなどの基本的なキー操作のみ、キーボードショートカットによる太字や斜体については、それぞれキーマップを定義する必要があるようです。
:::

## Angularコンポーネントの埋め込み
既に色々な所が気になってはいるものの、本来の目的であるAngularコンポーネントの埋め込みを実現するため、前に進みたいと思います。（コード全体は[こちら](https://stackblitz.com/edit/angular-prosemirror-embed?file=src%2Feditor.component.ts)）

大まかには以下の手順でTodoを入力するためのコンポーネントをProseMirrorで表示するところまでを解説していきます。
1. Todoコンポーネントを作成する
1. TodoコンポーネントをCustom Element (todo-element) として利用できるようにする
1. NodeViewインターフェースを実装したTodoViewでtodo-elementを描画する
1. todoNodeSpecを定義し、Schmaに組み込む

### Todoコンポーネントの作成
まずは埋め込むための Todoコンポーネントを作成します。
チェックボックスとTodo入力フィールドを備えたシンプルなコンポーネントです。
```typescript:todo.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-todo',
  standalone: true,
  template: `
    <div style="display:flex; flex-align:center">
      <input type="checkbox"/>
      <input type="text" placeholder="Input ToDo" style="border: none;"/>
    </div>
  `,
})
export class TodoComponent {}
```

### Custom Elements として利用できるようにする
続いて、Todoコンポーネントを Custom Elements として利用するために、bootstrapApplication で todo-element を定義します。Angular Elements の登場から6年が経ちましたが、初めて利用しました。宣言自体は非常に簡潔なのですね。
```diff_typescript:main.ts
import { Component } from '@angular/core';
+ import { createCustomElement } from '@angular/elements';
import { bootstrapApplication } from '@angular/platform-browser';
import { EditorComponent } from './editor.component';
+ import { TodoComponent } from './todo.component';

@Component({
  selector: 'app-root',
  standalone: true,
  template: `<app-editor />`,
  imports: [EditorComponent],
})
export class App {}

- bootstrapApplication(App);
+ bootstrapApplication(App).then((app) => {
+   const todoElement = createCustomElement(TodoComponent, {
+     injector: app.injector,
+   });
+   customElements.define('todo-element', todoElement);
+ });
```

### TodoView の作成
`todo-element`を利用する準備が整ったら、続いて`TodoView`を作成していきます。
NodeViewインターフェースを実装することで、ProseMirrorエディタ内でカスタムノードの表示と動作を制御することができるようになります。
ポイントとしてはコンストラクターで`Injector`を注入することで、`TodoView`内で`Renderer2`を用いてDOMを生成しているところです。
```typescript:todo.view.ts
import { Injector, Renderer2 } from '@angular/core';
import { Node } from 'prosemirror-model';
import { Decoration, DecorationSource, NodeView } from 'prosemirror-view';

export class TodoView implements NodeView {
  private readonly renderer = this.injector.get(Renderer2);

  readonly dom = this.renderer.createElement('todo-element');

  action = (random: any) => '';

  constructor(private injector: Injector) {
    this.dom.addEventListener('result', this.action);
  }

  update(
    node: Node,
    decorations: readonly Decoration[],
    innerDecorations: DecorationSource
  ): boolean {
    return true;
  }

  selectNode(): void {
    this.dom.setAttribute('selected', true);
  }

  deselectNode(): void {
    this.dom.setAttribute('selected', false);
  }

  ignoreMutation(mutation: MutationRecord): boolean {
    return true;
  }

  stopEvent(event: Event): boolean {
    return event.target !== this.dom.children[0];
  }

  destroy(): void {
    this.dom.removeEventListener('result', this.action);
  }
}
```

### スキーマ定義への組み込み
ようやく`TodoView`をカスタムノードとして利用する準備が整ったので、スキーマ定義に組み込んでいきます。同時にメニューバーに`New Todo`ボタンを追加して、ボタンがクリックされたら`TodoView`を表示するようにします。

```diff_typescript:editor.component.ts
import {
  AfterViewInit,
  Component,
  ElementRef,
+ inject,
+ Injector,
  viewChild,
} from '@angular/core';
import { baseKeymap } from 'prosemirror-commands';
import { keymap } from 'prosemirror-keymap';
+ import { menuBar, MenuItem } from 'prosemirror-menu';
- import { Schema } from 'prosemirror-model';
+ import { NodeSpec, Schema } from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
+ import { TodoView } from './todo.view';

+ const todoNodeSpec: NodeSpec = {
+   todo: {
+     group: 'block',
+     toDOM() {
+       return ['div', { 'data-type': 'todo' }];
+     },
+     parseDOM: [
+       {
+         tag: 'div[data-type=todo]',
+         getAttrs() {
+           return {};
+         },
+       },
+     ],
+   },
+ };

@Component({
  selector: 'app-editor',
  standalone: true,
  template: `
    <div #editor style="border: solid 1px silver"></div>
    <h2>editorView.state.doc</h2>
    {{ editorView?.state?.doc }}
    <h2>editorView.dom.innerHTML</h2>
    {{ editorView?.dom?.innerHTML }}
  `,
})
export class EditorComponent implements AfterViewInit {
+ private readonly injector = inject(Injector);
  private readonly editor =
    viewChild.required<ElementRef<HTMLDivElement>>('editor');

  editorView?: EditorView;

  ngAfterViewInit() {
    const mySchema = new Schema({
-     nodes: schema.spec.nodes,
+     nodes: schema.spec.nodes.append(todoNodeSpec),
      marks: schema.spec.marks,
    });
    this.editorView = new EditorView(this.editor().nativeElement, {
      state: EditorState.create({ schema: mySchema }),
      nodeViews: {
        todo: () => new TodoView(this.injector),
      },
      plugins: [
        keymap(baseKeymap),
+       menuBar({
+         content: [
+           [
+             new MenuItem({
+               title: 'New Todo',
+               label: 'New Todo',
+               run: ({ tr }, dispatch) => {
+                 const todoNode = mySchema.nodes['todo'].create();
+                 dispatch(tr.replaceSelectionWith(todoNode).scrollIntoView());
+                 return true;
+               },
+             }),
+           ],
+         ],
+       }),
      ],
    });
  }
}
```

一通りの手順が完了したので、`NewTodo`をクリックしてみると`TodoView`がちゃんと表示されました！
キャレットが消えてしまったり、ProseMirrorの挙動に馴染まない点がまだありますが、この問題はNodeView内での選択範囲の扱い方を調整することで解決できる可能性があります。詳細な解決方法については今後の課題とします。
![Angularコンポーネントを埋め込んだ結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130472/8f18b35d-4088-b8f9-26f1-506a2a0faa37.png)

## おわりに
今回はProseMirrorにAngularコンポーネントを埋め込むことを目的としたこともあり、ProseMirror自体の解説が薄いものになってしまいましたが、ふんわりとでもProseMirrorの雰囲気を掴んでいただければ幸いです。

Quillと比較して、最初はProseMirrorの思想や各モジュールの理解が難しそうだなと感じていました。しかし、理解が進むにつれてドキュメント構造を厳密に定義でき、定義した通りに動いてくれるProseMirrorはコードを書いていて面白みがあるなと感じてきました。

リッチテキストエディターに何を利用しようか迷われている方がおられたら、是非ProseMirrorを試してみるのも良いかと思います！

明日はバックエンドエンジニアの [@ktkr-wks](https://qiita.com/ktkr-wks) さんです！

[^1]: 平日のみの投稿なので16日ですが、11日目の記事としています。