Tiptapを使ってリッチエディタを自由にカスタマイズ

# はじめに

フロントエンドにリッチエディタを追加したい場合、多くの候補がありますが
UI変更に関するAPIは多数提供されていてもHTML属性の追加やパース・レンダリング処理のフックなど
細かい部分まで自由に変更可能なものは少ないように思います。

Tiptapはヘッドレスエディタフレームワークで自由に拡張機能の追加変更が可能で
コンテンツの構造もスキーマで管理されておりjsonなどで結果が得られるため内容の解析も容易となっています。

エディタのここの機能がもう少し変更出来たら要件にぴったりなのに、というときに使えるフレームワークです。
今回は主に拡張機能について使い方の解説をしていきます。

https://github.com/ueberdosis/tiptap

# Tiptapの特徴

## ヘッドレス

UIの提供をせず機能のみが提供されています。
UIフレームワークと組み合わせることで他のリッチエディタと同等の機能も再現可能です。

## フレームワークに依存しない

有名なフレームワークではだいたい動作するようです。

- Vanilla JavaScript
- React
- Next.js
- Vue 3
- Vue 2
- Nuxt.js
- Svelte

## 拡張が容易

すべての拡張機能にはextend()メソッドが定義されていて必要な部分のみ変更や追加が可能です。

## TypeScript対応

Tiptapのコードベース全体はTypeScriptで記述されています。

## 取得可能なコンテンツ

HTML、JSON、Textに対応しているため必要に応じた形式で取得して解析が可能です

## 初期化可能なコンテンツ

HTML、JSONどちらでも初期化可能です。

contentがエディタの初期値
```typescript
const editor = useEditor({
    content: '<p>Hello World!</p>',
})
```

contentの型
```typescript
export type Content = HTMLContent | JSONContent | JSONContent[] | null
```

# 既存の拡張機能を拡張する

## メソッドの上書き

元にしたい拡張機能をimportしてextendでメソッドを上書き出来ます。

```typescript
import BulletList from '@tiptap/extension-bullet-list'

const CustomBulletList = BulletList.extend({
  addKeyboardShortcuts() {
    return {
      'Mod-l': () => this.editor.commands.toggleBulletList(),
    }
  },
})
```


## オプションの追加

```typescript
export interface CustomSomeExtensionOptions extends SomeExtensionOptions {
  awesomeness: number,
}

const CustomSomeExtension = SomeExtensionOptions.extend<CustomSomeExtensionOptions>({
  addOptions() {
    return {
      ...this.parent?.(),
      awesomeness: 100,
    }
  },
})
```

オプションはロード時に拡張機能を初期化するときに使います。
デフォルト以外のオプションを追加したい場合`addOptions()`を追加します。
元の拡張機能のオプションを引き継ぎたい場合は継承元のオプションinterfaceをextendsして
interfaceを追加して拡張機能のの1つ目のジェネリック型に設定。
`addOptions()`に`...this.parent?.()`を追加します。

追加したオプションはuseEditorのextensionsで
使用する拡張機能を設定するときconfigureでオプションを指定できます。
例えば下記のような形になります。

`CustomSomeExtension.configure({ awesomeness: 200 })`

## 属性の追加

```typescript
addAttributes() {
    return {
        ...this.parent?.(),
        ticket: {
            default: null,
            parseHTML: element => element.style.ticket,
            renderHTML: (attributes) => {
                return {
                    style: `ticket: ${attributes.ticket}`,
                }
            },
        },
    }
}
```

属性はロード後に拡張機能に対して設定したい値がある場合などで使います。
`addAttributes()`を定義して属性を追加出来ます。
`...this.parent?.()`は拡張元の属性を継承しています。

属性の設定として下記のようなものがあります。

`renderHTML`: HTML出力時にカスタマイズする
`parseHTML`: HTMLに設定した値をパースする
`rendered`: HTML出力時にレンダリングするかどうかを指定できます

`renderHTML`と`parseHTML`の組み合わせでエディタに自由な名前と型（HTMLで解析できる範囲）で属性を設定できます。

## グローバル属性

```typescript
import { Extension } from '@tiptap/core'

const TextAlign = Extension.create({
  addGlobalAttributes() {
    return [
      {
        // Extend the following extensions
        types: [
          'heading',
          'paragraph',
        ],
        // … with those attributes
        attributes: {
          textAlign: {
            default: 'left',
            renderHTML: attributes => ({
              style: `text-align: ${attributes.textAlign}`,
            }),
            parseHTML: element => element.style.textAlign || 'left',
          },
        },
      },
    ]
  },
})
```

他の拡張機能に対して属性を設定したいときがあります。
その時は`addGlobalAttributes()`で指定できます。

例えばParagraph拡張機能はブロックレベル要素の`<p>`タグに対して
`renderHTML`と`parseHTML`をしていますが同じ場所に属性を追加したいときに使います。

## コマンドの追加

```typescript
import { Extension } from '@tiptap/core'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    customExtension: {
      /**
       * Comments will be added to the autocomplete.
       */
      yourCommand: (someProp: any) => ReturnType,
    }
  }
}

const CustomExtension = Extension.create({
  addCommands() {
    return {
       ...this.parent?.(),
        yourCommand: someProp => ({ commands }) => {
            // …
        },
    }
  },
})
```

`...this.parent?.()`は拡張元の属性を継承しています。
継承しない場合は`addOptions()`が上書きされて`yourCommand`のみが有効になります

## 現在の値を取得する

```typescript
this.editor.getAttributes('link').href
```

getAttributesで拡張機能の名前を指定して属性の現在の値を取得出来ます
addGlobalAttributesで別の拡張機能に設定している場合は設定した拡張機能の名前を指定します。

# まとめ

フロントエンド開発でUIフレームワークを既に使っていてもヘッドレスなので他の表示を邪魔せず
エディタ機能だけTiptap利用して拡張して使うことも出来るので便利だなと感じています。
わりと思ったとおり作れるのでリッチエディタのカスタマイズで困ったときにTiptapは良い選択肢の一つだと思います。