📚

Tiptap:Headingブロック先頭のdeleteでparagraphにしたい

に公開

皆さん Tiptap 使ってますか?
Tiptap には様々な機能がデフォルトで用意されていますが、その中の1つに Heading があります。

https://tiptap.dev/docs/editor/extensions/nodes/heading

用意されているのはありがたいのですが、細かい挙動で気になるところがありました。

それがタイトルです。Heading ブロック先頭の delete で paragraph にしたい。

前の行が空行だと、前の行を削除して Heading ブロック全体を上げる処理になります。
また、Headingが文章の先頭にある場合は何も起きません。

前の行が文字ありの paragraph だと、結合処理になって Heading が消えます。

意図する挙動としては、先頭で delete をすると前の行に関わらず中身のテキストを保持しながら paragraph にして欲しいです。

Notion はこのようになっており、キーボード上で直感的に Heading をオフにできるので使いやすいです。

今回の実装を試せるサイト

https://karintou8710.github.io/tiptap-editor-sample/

実装タイム

Heading を拡張して実装します。

import TiptapHeading from "@tiptap/extension-heading";

const Heading = TiptapHeading.extend({
  addKeyboardShortcuts() {
    const baseShortcuts =
      TiptapHeading.config.addKeyboardShortcuts?.call(this) || {};
    return {
      ...baseShortcuts,
      Backspace: () => {
        const { selection } = this.editor.state;
        const { $from } = selection;
        if ($from.node().type.name !== this.name) return false;

        // ブロックの先頭で削除か
        if (!selection.empty || $from.start() !== $from.pos) return false;

        return this.editor.commands.setParagraph();
      },
    };
  },
});

export default Heading;

さらっと解説すると、まずは Backspace が呼ばれた時に Heading 内 && ブロックの先頭にいるかチェック。その後に、setParagraph を呼ぶだけです。

baseShortcuts は Heading 元々のショートカットを継承するようにしています。
他にいい書き方ありそう。

おまけ

他にも、Heading 途中で Enter を押すと、Heading が2つに分かれるという挙動があります。

僕的には、先頭以外で Enter を押すと後続のテキストは paragraph として分割されて欲しいです。

なのでそのコードも追加します。

import TiptapHeading from "@tiptap/extension-heading";
import { splitBlockAs } from "@tiptap/pm/commands";

const Heading = TiptapHeading.extend({
  addKeyboardShortcuts() {
    const baseShortcuts =
      TiptapHeading.config.addKeyboardShortcuts?.call(this) || {};
    return {
      ...baseShortcuts,
      Enter: () => {
        const { selection } = this.editor.state;
        const { $from } = selection;
        if ($from.node().type.name !== this.name) return false;
        if ($from.start() === $from.pos) return false;

        return splitBlockAs(() => {
          return {
            type: this.editor.schema.nodes.paragraph,
          };
        })(this.editor.state, this.editor.view.dispatch);
      },
    };
  },
});

export default Heading;

Tiptap 側に良いコマンドがなかったので、ProseMirror の splitBlockAs を採用しました。

引数で分割された後続のノードを指定することができます。

おわりに

意外とこういう細かい挙動の解説なかったので、書いてみました。参考になれば幸いです。

追記
parentを使えば継承を綺麗にかけた

Discussion