TiptapのExtensionを使って見出し(ハイライト)機能をリリースしました

TiptapのExtensionを使って見出し(ハイライト)機能をリリースしました
  • URLをコピーしました!

こんにちは。フロントエンドエンジニアの古園です。

PR TIMESでは日々エディターの機能開発を行っています。

今回はそんな中から多くのユーザーから要望があり、開発がスタートした見出し(ハイライト)機能をTiptapのExtensionを使用して開発した件について解説します。

目次

Tiptapについて

まず解説の前提となるエディターライブラリ、Tiptapについて軽く触れておきます。

Tiptapはモダンなリッチテキストエディタの基盤として使用されているヘッドレスフレームワークで Extensionという数多くの拡張機能が用意されています。それらを駆使することで思い通りの見た目・機能を提供することが可能です。

PR TIMESでも今回解説する見出しや画像・ボタン・表などのExtensionを自作、もしくは公式が作成しているExtensionを継承して日々開発を行なっています。

見出し(ハイライト)機能とは

見出し(ハイライト)機能とはグレーのハイライトをつけた見出しを設定できる機能です。

エディターのツールバーにある見出しボタン内から使用できます。

エディタの画面に見出しの選択肢が表示されている様子。中見出し(ハイライト)と小見出し(ハイライト)のオプションが含まれている。
エディター 中見出し(ハイライト)と小見出し(ハイライト)の使用画面

見出し(ハイライト)機能の歴史

現在のエディターでは上記のようにボタンから選択して設定するのですが、それ以前のエディターでは見出し(ハイライト)機能そのものがありませんでした。しかし、プレスリリースの見出しである部分を目立たせるためにハイライトが利用できるリスト機能を使う工夫をするユーザーの方が多くいらっしゃいました。リスト機能を利用するとHTMLは以下のようになるため、セマンティックなHTMLにならず、アクセシビリティの観点でもスクリーンリーダーの見出しジャンプが使用できないなどの問題がありました。

// 旧エディターのハイライト見出しを行った時のDOM構造
<ul>
	<li>
		<p>見出し</p>
	</li>
</ul>
リスト機能をを見出しとして使用したプレスリリース

以上を考慮して現在のエディターではHTML構造を意識した見出し機能を提供したのですが、以前のエディターのハイライト付きの見た目を好んでいたユーザーが多く、見出し(ハイライト)機能を現在のエディターでも実現してほしいという要望が数多く寄せられていました。

そこで既にリリースされている中見出し、小見出しの機能を改修してハイライト付きの見出しも設定できるようにする方針で開発がスタートしました。

見出し(ハイライト)機能開発

私自身Tiptapを使用しての開発は初めてだったこともあり、まずは

  • 開発に関わってくるTiptapの機能調査
  • 既存の見出しタグの実装を基にした設計作成

からスタートしました。

開発に関わってくるTiptapの機能調査

PR TIMESでは画像、リスト、表など数多くの機能が拡張機能をベースに作成されています。見出し機能もその1つで既存のクラスを拡張する形で使用しています。

import {Heading} from '@tiptap/extension-heading';

type Level = 2 | 3;

type HeadingOptions = {
  levels: Level[];
  HTMLAttributes: Record<string, any>;
};

export const HeadingExtension = Heading.extend<HeadingOptions>({
  name: 'heading',

  /**
    * 拡張の初期設定や動作をカスタマイズするのに必要な値を設定できます。
    * ここでは初期値のHTMLAttributesとlevelsの値を2, 3に絞るようにしています。
    * PR TIMESのエディター本文は中身出し(h2)と小見出し(h3)しか
    * 設定ができないようにしているためこのようにバリデーションをかけています。
    */
  addOptions() {
    return {
      levels: [2, 3],
      HTMLAttributes: {},
    };
  },

  /**
    * 拡張機能にカスタム属性、この実装の場合は見出しレベルの出し分けを行うために使用しています。
    * こうすることで中身出しと小見出しそれぞれの拡張機能を用意する必要がなくなり、
    * 外部からlevelを渡すことでエディターでの表示を出し分けることができます。
    */
  addAttributes() {
    return {
      level: {
        default: 2,
        rendered: false,
      },
    };
  },

  /**
    * 外部からDOMデータが渡ってきた際にパースして拡張機能の設定を当てるために必要な機能です。
    * PR TIMESの場合はエディターで記載した内容を保存することができるので編集ページに
    * アクセスした際にデータを呼び出して最後に記載した状態に戻す時に活躍しています。
    */
  parseHTML() {
    return this.options.levels?.map((level) => ({
      tag: `h${level}`,
      attrs: {level},
    }));
  },

  /**
    * 最終的にどのようなHTMLを書き出すかの設定を行なっています。
    * エディターページと公開後のプレスリリースページのdom構造は違っており、
    * それぞれの機能がどのように書き出されるかをここで指定しています。
    */
  renderHTML({node}) {
    const levels: Level[] = this.options.levels ?? [];
    const hasLevel = levels.includes(node.attrs.level as Level);
    const level: Level = hasLevel ? (node.attrs.level as Level) : levels[0];

    return [`h${level}`, 0];
  },
});

コードは上記の通りです。

また、独自のエディター用コマンドを追加したい場合に記載するaddCommandsもあります。

機能調査を行った感想

開発当時は既に実装済みのものですらどれが何をするために必要なのか調べても分からず、parseHTMLに至っては設計中、そして実装を開始してしばらくしてからも必要な場面に出くわさず、どこで使用するのか、もしくは必要ないのではないかと考えてしまっていたのを覚えています。

Tiptapは理解するとそれぞれがどういったことのために必要なのかが分かりやすいのですが、それらを理解するまでが大変です。また、公式ドキュメントが英語しかないので訳しながらだと何を意味するのか読み取りにくい箇所があるのが理解をさらに難しくしている可能性はあるかもしれません。

また、それに加えてTiptapの拡張機能に関するキャッチアップを難しくしたのが記事の少なさです。国内でも使用事例が徐々に増えてきている気配がありますが、まだまだReactなどのメジャーなものと比べると少ない状態です。海外の記事も充実しているとは言えず、実装に必要そうな機能を発見したら個人用のリポジトリにTiptapを試せる環境を作成し、1つ1つ試していくことで地道に理解を深めていきました。

既存の見出しタグの実装を基にした設計作成

以上の機能調査を元に実装は段階に分けて行うことにしました。具体的には

  • 中・小見出し(ハイライト)ボタンを使用してエディター画面に中・小見出し(ハイライト)を表示させる
  • 保存して再度ページに戻ってきた時に中・小見出し(ハイライト)が表示できている

の2段階です。必要なデータが前者はボタン押下時、後者はページアクセス時にDBに保存された値をパースしてといった形で拡張機能に渡ってくる経路が違います。なのでやりたいことは似ていますが、実装自体は分けています。

改修したコード

最終的には以下のような形となりました。

import {Heading, type Level} from '@tiptap/extension-heading';

type HeadingOptions = {
  levels: Level[];
  HTMLAttributes: Record<string, any>;
};

type PressReleaseHeaderHighlightAttributes = {
  level: Level;
  class: string;
};

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    'pr-heading': {
      toggleHeaderWithHighlight: (
        attributes: Partial<PressReleaseHeaderHighlightAttributes>,
      ) => ReturnType;
    };
  }
}

export const HeadingExtension = Heading.extend<HeadingOptions>({
  name: 'pr_heading',

  addOptions() {
    return {
      levels: [2, 3],
      HTMLAttributes: {},
    };
  },

  addAttributes() {
    return {
      level: {
        default: 2,
        rendered: false,
      },
      class: {
        default: '',
      },
    };
  },

  addCommands() {
    return {
      ...this.parent?.(),
      toggleHeaderWithHighlight:
        (attributes) =>
        ({commands}) => {
          const {level} = attributes;

          if (level === undefined || !this.options.levels.includes(level)) {
            return false;
          }

          return commands.toggleNode(this.name, 'paragraph', {
            ...attributes,
          });
        },
    };
  },

  parseHTML() {
    return this.options.levels?.map((level) => ({
      tag: `h${level}`,
      attrs: {level},
    }));
  },

  renderHTML({node}) {
    const {levels} = this.options;
    const {level, class: className} =
      node.attrs as PressReleaseHeaderHighlightAttributes;
    const headerLevel = levels.includes(level) ? level : levels[0];

		// classNameがあれば見出し(ハイライト)、なければ見出しになるようにする
    return className === ''
			// RSS用にハイライトでない見出しはクラス属性が付かないようにする必要があります
      ? [`h${headerLevel}`, 0]
      : [`h${headerLevel}`, {class: className}, 0];
  },
});

この実装で工夫した点はハイライトかそうでないかの判断をclassの有無のみに集約したことです。ハイライトのデザイン(CSS)はclassに設定しており、かつ拡張機能側でハイライト付きの見出しか普通の見出しかの判別もclassの有無で行える設計にすることで余分な変数を用意したりすることなく簡潔に実装することができました。

開発中の失敗

最終的にはコードや設計を綺麗にまとめることができましたが、その途中で開発が順調であったかというとそうではなく、ここに至るまでに大きな失敗がありました。

当初の設計

当初は

editor.chain().focus().toggleHeaderWithHighlight({level, isHighlight}).run();

といった形で isHighlight というフラグを渡すやり方で進めていました。

その時の拡張機能は以下の通りです。

export const HeadingExtension = Heading.extend<HeadingOptions>({
  name: 'pr_heading',

  addOptions() {
    return {
      levels: [2, 3],
      HTMLAttributes: {},
    };
  },

  addAttributes() {
    return {
      level: {
        default: 2,
        rendered: false,
      },
      isHighlight: {
        default: false,
      },
    };
  },

  addCommands() {
    return {
      ...this.parent?.(),
      toggleHeaderWithHighlight:
        (attributes) =>
        ({commands}) => {
          return commands.toggleNode(this.name, 'paragraph', {
            ...attributes,
          });
        },
    };
  },

  parseHTML() {
    return this.options.levels?.map((level) => ({
      tag: `h${level}`,
      attrs: {level},
    }));
  },

  renderHTML({node}) {
    const levels: Level[] = this.options.levels ?? [];
    const hasLevel = levels.includes(node.attrs.level as Level);
    const level: Level = hasLevel ? (node.attrs.level as Level) : levels[0];
    const isHighlight = node.attrs.isHighlight as boolean;

    return isHighlight
      ? [`h${level}`, {class: 'pr-header--highlight'}, 0]
      : [`h${level}`, 0];
  },
});

node.attrs.isHighlightで値を受け取り、それを元にクラスを出し分けることで実際に動いていました。ですが、ここからページアクセス時に表示させる方をやろうとしてもどうやっても上手くいかず、Tiptapの開発経験がある同僚に話を聞きに行きました。その結果、

  • ページアクセス時はDBから取得した値をパースして反映する必要がある
  • 今の実装だとisHighlightを受け取ることが前提となっているが、ボタンで作成した際にはクラスを付与しているだけでisHighlightは付与していない。つまりパースすることができない設計になっている

の2点が判明。ここでparseHTMLの役割を理解するとともにボタンを押した時とDBから取得した時の両方で使用する値が統一されていなければならないことに気がつきました。

設計のやり直し

isHighlightが使用できないことが分かったので最終的にDOMにも付与されるためパースする上で区別をつけることができるクラスを使用してやり直すことにしました。

そうするとまずボタンから拡張機能への渡す方は

editor.chain().focus().toggleHeaderWithHighlight({level: 2, class: 'pr-header--highlight'}).run();

となり、受け取る側も

renderHTML({node}) {
  const {levels} = this.options;
  const {level, class: className} =
    node.attrs as PressReleaseHeaderHighlightAttributes;
  const headerLevel = levels.includes(level) ? level : levels[0];

	// RSS用にハイライトでない見出しはクラス属性が付かないようにするする必要があります
  return className === ''
    ? [`h${headerLevel}`, 0]
    : [`h${headerLevel}`, {class: className}, 0];
},

と現在の形式になりました。

そして、DBから取得する方もparseHTMLを以下のようにクラス付きもパースできるようにして無事機能を完成させることができました。

(しばらく後で tag: `h${level}` の方だけでクラスがついている方もパースできることに気がつき、先に解説した形に直しました)

parseHTML() {
  const normals = this.options.levels.map((level) => ({
    tag: `h${level}`,
    attrs: {level},
  }));
  const highlights = this.options.levels.map((level) => ({
    tag: `h${level}.pr-header--highlight`,
    attrs: {level},
  }));

  return [...normals, ...highlights];
},

以上のように開発前の調査・設計には十分気を払っていたのですが、はまってしまい、一度設計からやり直して再度実装し直す失敗がありました。やり直しが決まった段階では、すでに機能リリースのスケジュールが確定しており、締め切りを遅らせるわけにはいかなかったため、焦りながらも一心不乱に実装作業に取り組んでいたことを記憶しています。

リリース後の反応

紆余曲折を経てリリースされた見出し(ハイライト)機能は予め告知されていなかったため、リリース当初は使用者が現れないことも危惧していました。しかし、1週間以内には既に複数のユーザーが使用してくださっているのを発見し個人的に嬉しかったのを覚えています。

また、Xでも以前のエディターでハイライト付きの見出しを作成していたことを懐かしむ投稿も見られたりと好評を頂けています!

最後に

弊社ではフロントエンドエンジニアの募集を通年で行っています!エディター開発やその他の機能開発に興味がある方は是非ご応募ください!

あわせて読みたい
株式会社PR TIMES
02.開発部 の求人一覧 - 株式会社PR TIMES 株式会社PR TIMESが公開している、02.開発部 の求人一覧です

  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 開発部 フロントエンドエンジニア

目次