プレスリリースのエディターでTiptapを使って新機能開発をした話

  • URLをコピーしました!

こんにちは。フロントエンドエンジニアの桐澤(@kiririLee)です。PR TIMESはプレスリリースを配信する企業様がプレスリリースを作成・編集するためのエディターを提供しています。昨年の12月にUIリニューアルプロジェクトの一環でUIの更新と新機能が追加されたエディターがリリースされました。現在(2024年9月時点)PR TIMES上には、UIリニューアル前のエディターとUIリニューアル後のエディターが存在し、それぞれ現エディターと新エディターと呼ばれますが、この記事は新エディターに関しての記事です。

現エディターは、React、Tiptap、TypeScriptにより開発されており、新エディターでもこの技術スタックが引き継がれています。新エディターで追加された新機能がTiptapによりどのように開発されているのか紹介します。

目次

配信者/問い合わせ先機能

新エディターで追加された配信者/問い合わせ先機能は、事前に保存しておいた配信者の情報や問い合わせ先の内容をプレスリリースに挿入できる機能です。これによって毎回同じ情報を手動で入力することなく、テンプレート的に自社の情報をプレスリリースに埋め込めます。

以下にこの機能を表現するために実装が必要な項目を並べます。

  • エディター編集用のツールバーから「配信者/問い合わせ先」をクリックして、エディター内にボタンを配置する
  • 配置されたボタンを押し、配信者情報が編集、選択できるポップオーバーを展開する
  • 配信者情報を新規追加、編集する場合は情報を入力する欄を持つモーダルを展開する
  • 作成済みの項目をクリックしてエディター内に配信者情報を挿入する

Reactコンポーネントであるポップオーバーを展開するボタンと配信者情報を編集するモーダルをエディター内に表示する必要があり、これを実装するためにNode viewsを使用しました。

TiptapのNode views

Tiptapでは機能ごとにExtensionをカスタマイズして開発を行なっていきます。Extensionでは機能を表現するためのHTMLのレンダリングやブラウザ上で発生する特定のイベントを機能に紐付けるなど機能拡張するための柔軟な設定ができます。その中でもNode viewsという設定項目がドキュメントでは高度な使用例として記載されています。

Tiptapのエディターで編集されたコンテンツは最終的なアウトプットはHTMLになります。セキュリティを考慮すると本来はエディターからHTMLを出力することは望ましくありません。しかし、PR TIMESのエディターはReactにリプレイスされた際に、過去のプレスリリースとの互換性を保つためにHTMLを出力しています。セキュリティ面は独自のサニタイザーを通すことで担保しています。詳しくは以下の記事をご覧ください。

あわせて読みたい
プレスリリースのエディタにサニタイザーを導入した話 こんにちは。フロントエンドエンジニアの桐澤(@kiririLee)です。PHPのアプリケーションから配信されるプレスリリースがサニタイザーを通るようにしたため、導入するま...

企業ユーザーによりPR TIMESのエディターで入稿されたプレスリリースも内容がHTMLで表現されて、そのHTMLがDBに保存され、そのまま一般ユーザーが閲覧する以下の「プレスリリースページ」に表示されます。

このように、エディターの機能と出力されるHTMLは密接に紐付いていますが、Node viewsはこのアウトプットのHTMLとは紐付かないHTMLをエディター上にレンダリングできます。これができることで最終的なアプトプットとは関係のない装飾部分を作り込むことができます。今回の配信者情報だと最終的なアプトプットとは関係ない装飾にあたるのは、ボタン・ポップオーバー・モーダルです。

配信者情報の実装

配信者情報機能の最終的なアウトプットとしては以下の構造をしたHTMLが出力されます。

<div class="pr-distributor">
  <img src="" alt="">
  <p class="pr-distributor__name">入力された会社名</p>
  <p class="pr-distributor__description">入力された会社情報</p>
</div>

しかし入力の動線として最初にエディター上に表示するのはポップオーバーを表示する以下のようなボタンです。

<button type="button">配信者情報</button>

Node viewsは以下のようにExtensionのaddNodeViewメソッドでReactNodeViewRendererの引数にReactコンポーネントを指定した戻り値を返すことで、Reactコンポーネントをエディター上にレンダリングできます。renderHTMLで最終的なアウトプットを指定できますが、<div class=”pr-distributor”>Preview Distributor</div>を出力するようにしています。この節の一番最初で示した最終的なHTMLと違っているのは、「配信者情報の内容を入力する動線」と「内容を表示するところ」でExtensionを分けているためです。以下のDistributorInputExtensionは配信者情報の内容を入力する動線のためのExtensionです。このExtensionが出力するHTMLをどのように最終的なHTMLに置き換えるかは後述します。

export const DistributorInputExtension = Node.create({
  name: 'pr_distributor_input',
  
  ...
  
  parseHTML() {
    return [{tag: .pr-distributor}];
  },
  renderHTML() {
    return ['div', {class: 'pr-distributor'}, 'Preview Distributor'];
  },
  addNodeView() {
    /* エディター上に表示したいReactコンポーネントを指定する */
    return ReactNodeViewRenderer(DistributorInputNodeView);
  }
  
  ...
  
});

このDistributorInputNodeViewコンポーネントはButtonをTriggerに持つRadix UIのPopoverをレンダリングします。配信者情報を編集するモーダルもレンダリングします。Reactコンポーネントであるためhooksも使うことができ、既存の配信者情報をAPIでバックエンドから取得してリスト表示したり、stateを用意してモーダルの開閉を制御したりしています。Node viewsとしてレンダリングしているReactコンポーネントのpropsからはeditorインスタンスや自身がエディター上で選択されているかどうかを示すフラグ、エディター上での自身の位置を取得する関数などが受け取れます。

export function DistributorInputNodeView({
  editor,
  selected,
  getPos,
  deleteNode,
}: NodeViewProps) {
  const {data: distributorList} = useGetDistributorList();

  const {modalType, setModalType, isOpen, setIsOpen} = useModalState();

  const openCreateDistributorModal = () => {
    setModalType('create');
    setIsOpen(true);
  };

  const openUpdateDistributorModal = () => {
    setModalType('update');
    setIsOpen(true);
  };

  const insertDistributor = async ({
    src,
    name,
    description,
  }: DistributorOptions) => {
    editor
      .chain()
      .focus()
      .setDistributorUnits(getPos(), {
        src,
        name,
        description,
      })
      .run();

    deleteNode();
  };

  function EntryButton() {
    return (
      <button
        type='button'
      >
        配信者情報
      </button>
    );
  }

  return (
    <NodeViewWrapper contentEditable={false}>
        <DistributorModal
          isOpen={isOpen}
          setIsOpen={setIsOpen}
          modalType={modalType}
        />
        <Popover trigger={EntryButton()} side='bottom'>
          <DistributorList
            isUploadingImage={isUploadingImage}
            distributorList={distributorList}
            openUpdateDistributorModal={openUpdateDistributorModal}
            insertDistributorIntoEditor={insertDistributor}
          />
          <List>
            <ListItem
              icon={<PlusIcon />}
              text='新規追加'
              onClickItem={openCreateDistributorModal}
            />
          </List>
        </Popover>
    </NodeViewWrapper>
  );
}

insertDistributorは選択された配信者情報をエディター内に挿入する関数です。引数として受け取っているsrc, name, descriptionは事前にモーダルから保存しておいたデータでAPIから取得された値です。editorインスタンスから別のExtensionで定義しているsetDistributorUnitsを呼び出し、deleteNodeで自身をエディター上から削除しています。

ここでポイントは以下の二つです。

  1. 別のExtensionで定義しているsetDistributorUnitsを呼び出してエディター上に配信者情報を挿入し、最終的なアウトプットとなるHTMLを出力している
  2. deleteNodeで自身を消すことで、「配信者情報の内容を入力する動線」として出力していたHTMLを削除して、コンテンツ上から不要なHTMLを取り除いている

一つ目で別のExtensionと書いたExtensionは配信者情報の内容を表示する以下のような実装になっています。addCommandssetDistributorUnitsを定義しています。commands.insertContentAtはTiptapから提供されるメソッドでエディター内の特定の場所に指定したコンテンツを挿入できます。typeにthis.nameと設定することで自身のExtensionを指定し、attrssetDistributorUnitsの引数として受け取ったsrc, name, descriptionを渡すことで自身のrenderHTMLで定義したHTMLを出力することができます。

export type DistributorAttributes = {
  src?: string | null;
  name: string;
  description: string
};

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    pr_distributor: {
      setDistributorUnits: (
        pos: number,
        attributes: DistributorAttributes,
      ) => ReturnType;
    };
  }
}

export const DistributorExtension = Node.create({
  name: 'pr_distributor',
  
  ...
  
  addAttributes() {
    return {
      src: {
        default: null,
      },
      name: {
        default: null,
      },
      description: {
        default: null,
      },
    };
  },
  addCommands() {
    return {
      setDistributorUnits:
        (pos, options) =>
        ({commands}) => {
          return commands.insertContentAt(
            pos,
            {
              type: this.name,
              attrs: {
                src: options.src,
                name: options.name,
                description: options.description
              },
            },
            {
              updateSelection: true,
            },
          );
        },
    };
  },
  parseHTML() {
    return [
      {
        tag: '.pr-distributor',
        getAttrs: getDistributorNodeAttributes,
      },
    ];
  },
  renderHTML({HTMLAttributes}) {
    const {
      src,
      name,
      description,
    } = HTMLAttributes as DistributorAttributes;

    const isNoImage = src === null || src === undefined;

    if (isNoImage) {
      return [
        'div',
        {class: 'pr-distributor'},
        ['p', {class: 'pr-distributor__name'}, name],
        ['p', {class: 'pr-distributor__description'}, description],
      ];
    }

    return [
      'div',
      {class: 'pr-distributor'},
      [
        'img',
        {
          src,
          alt: name,
        },
      ],
      ['p', {class: 'pr-distributor__name'}, name],
      ['p', {class: 'pr-distributor__description'}, description],
    ];
  },
  
  ...
  
});

renderHTMLで出力するHTMLは以下のようになっており、この節の一番最初に示した最終的なアウトプットとしてのHTMLと一致しています。配信者情報は画像なしで登録できるためsrcの有無でimgタグを出力するかしないかを出し分けています。

<!-- 画像が存在する場合 -->
<div class="pr-distributor">
  <img src="https://example.com/150x150.png" alt="{name}">
  <p class="pr-distributor__name">{name}</p>
  <p class="pr-distributor__description">{description}</p>
</div>

<!-- 画像が存在しない場合 -->
<div class="pr-distributor">
  <p class="pr-distributor__name">{name}</p>
  <p class="pr-distributor__description">{description}</p>
</div>

一連のポイントをまとめると次のようになります。

  • 「配信者情報の内容を入力する動線」と「配信者情報の内容を表示する部分」でそれぞれExtensionを分けて実装する
    • 配信者情報の内容を入力する動線はNode viewsを使用してReactコンポーネントで実装する
    • 配信者情報の内容を表示する部分のExtensionでは、配信者情報を挿入するコマンド、最終的に出力するHTMLを定義するExtensionを実装する
  • 配信者情報をエディターに挿入する際はNode viewsから別のExtensionのメソッドを呼び出す
    • メソッドを呼び出す側のExtensionで出力したHTMLは削除する
    • 呼び出したメソッドにより最終的なHTMLを出力する。このHTMLは別のExtension側で定義する。

囲み機能

囲み機能は文章の周りを黒線で囲める機能です。Notionのコールアウトのように文章を目立たせることができます。

機能的には以下のようになっています。

  • ツールバーから囲みを選択するとエディターに黒枠が設置される
  • 黒枠の中に文章を打ち込むことができる

実装

囲みはNode viewsを使用していません。以下のようにpr_boxという名前でBoxExtensionを作成しています。

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    pr_box: {
      setBox: () => ReturnType;
    };
  }
}

export const BoxExtension = Node.create({
  name: 'pr_box',
  group: 'block',
  content: 'paragraph*',
 
  ...
 
  addOptions() {
    return {
      HTMLAttributes: {class: 'pr-box'},
    };
  },
  addCommands() {
    return {
      setBox:
        () =>
        ({commands}) => {
          return commands.insertContent({
            type: this.name,
            content: [{type: 'paragraph'}],
          });
        },
    };
  },
  parseHTML() {
    return [
      {tag: '.pr-box'},
    ];
  },
  renderHTML({HTMLAttributes}) {
    return [
      'div',
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
      0,
    ];
  },
});

renderHTML<div class=”pr-box”></div> を出力するようにしていますが、以下のようにreturnしている配列の最後の0ホール(hole)と呼ばれており他のExtensionで処理されるHTMLが入ってきます。囲みの場合はschemaのcontentparagraph*を指定しているため、このホールの部分に0個以上のparagraphが入ってくることを期待しています。

  renderHTML({HTMLAttributes}) {
    return [
      'div',
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
      /* ホール(hole)の指定 */
      0,
    ];
  },

contentではschemaを設定しますが、このcontentで指定したコンテンツ以外はpr_boxの内側(div.pr-boxの子要素)に他の要素は入れることができません。もし<div class=”pr-box”><ul><li></il></ul></div>のように子要素にリストが入るようなコンテンツを読み込んだ場合、content設定で許容していないため子要素のul, liタグは削除されます。

PR TIMESではTiptap公式が提供するParagraphExtensionを使用しています。ParagraphExtensionを見るとrenderHTMLでpタグとholeを指定しており、contentinline*を指定しています。BoxExtensionのcontentはparagraphを指定し、ParagraphExtensionのcontentinlineを指定しているため、合わせると囲み機能は最終的に<div class=”pr-box”><p>テキスト</p></div> をアウトプットするようになります。テキストの部分はTextExtensionでテキストの入力ができます。

囲みの黒枠の見た目はCSSから設定します。

.pr-box {
  border: 1px solid black;
  border-radius: 8px;
  padding: 20px;
  
  & > p {
    line-height: 32.4px;
  }
}

editorインスタンスから細かい挙動を制御する

紹介したBoxExtensionの実装ではもう少し細かい挙動を制御したい箇所があります。今の実装だと囲みの中に段落が複数ある状態で、段落の中間でEnterを押して段落を追加した場合に囲みが分裂してしまいます。この挙動はQAチームからもバグとして起票され修正する必要がありました。

このバグが発生する条件は囲みの中で、かつ段落テキストの最後の文字にカーソルがある状態でEnterが押された場合に発生するため、まずはその条件をエディター上から検知する必要があります。まとめると以下の3つを検知したいです。

  1. Enterが押されたこと
  2. 囲みの中であること
  3. 段落テキストの最後の文字にカーソルがあること

一つ目はExtensionから設定できるaddKeybordShortcutsで検知することができます。次のように検知したいキー名をオブジェクトメソッドの名前にして任意のキーを検知できます。Enterを検知したいためキーの名前をEnterにしています。このEnterメソッドは必ずBooleanを返す必要があり、trueを返した場合はこのExtensionで設定したEnterメソッドで後続の処理を止めることができます。後続の処理とはEnterを押した時のブラウザ標準の挙動(改行)と他のExtensionで設定しているEnterメソッドによる処理を示します。今回は改行の処理自体はこのメソッドで制御しないためfalseを返します。今の状態だとEnterをフックできるようにしただけで動作自体は何も変わっていません(trueを返した場合、他のExtensionで設定しているEnterの処理も全て止まってしまうため、trueを返す場面はあまりないと思います)。

export const BoxExtension = Node.create({
  name: 'pr_box',

	...
  
  addKeyboardShortcuts() {
    return {
      Enter({editor}) {
        console.log('on enter!!')

        return false
      }
    }
  }
});

二つ目は次のような関数を作ることで検知できます。editor.state.selectionはカーソルがある位置、範囲選択されている位置など現在のエディター内でのカーソルの位置情報に関する情報にアクセスできます。$headを参照していますが、これは範囲選択されている場合にカーソル終わりの位置を示します。範囲選択の始まりの位置は$anchorから参照できます。範囲選択されていない場合は$anchor$headは同じ位置を示します。今回は範囲選択は想定していないため$anchor$headどちらでも良い状況ですが$headを参照しています(余談ですが、$from$toというプロパティも参照できます。これも$anchor$headと同じ情報を持ち、$from$anchor$head$toに相当します)。

$head.depthはカーソルがある位置のNodeの深さを示します。この節の少し前に戻りますが、BoxExtensionはholeを使用して他のExtensionで処理するNodeを自身のExtensionの子要素として持っていました。BoxExtension > ParagraphExtension > TextExtensionというNodeの構造を持っており、depthはカーソルが現在どの階層にあるのかという情報を返します。このdepthを元に$head.node(depth)を実行することで現在カーソルが当たっているNodeを取得できます。

isInsideBoxではカーソルが当たっているdepthの一番深いところから一つ上のNodeを順番に見ていきカーソルがBoxExtensionの中にあるかどうかを判定しています。そしてもしBoxExtensionの中にカーソルがあった場合はBoxNodeを返し、なかった場合はfalseを返します。

function isInsideBox(editor: Editor) {
  const $head = editor.state.selection.$head;
  for (let depth = $head.depth; depth > 0; depth--) {
    if ($head.node(depth).type.name === BoxExtension.name)
      return $head.node(depth);
  }

  return false;
}

三つ目の段落テキストの最後の文字にカーソルがあることを検知するためには以下のようにします。テキストにカーソルが合わせらていた場合は、editor.state.selectionTextSelectionになるためselection instanceof TextSelectionでテキストにカーソルが合わせられているかどうかをまず確認します。そしてカーソルが段落テキストの最後にあるかどうかは$form.end() === $from.posによって判定しています。

段落テキストの最後というのはpタグの内側にあるテキストの最後の文字ということを示しています。<div class=”pr-box”><p>ほげ|</p></div>だった場合( | はカーソルの位置を表す)、「ほげ」というテキストの「げ」の後にカーソルがあるかどうかを示しています。つまり、「ほげ」の「ほ」の後にカーソルがあった場合、$form.end() === $from.posfalseになります。

addKeyboardShortcuts() {
    return {
      Enter({editor}) {
        const {selection} = editor.state
        const {$from} = selection

        const boxNode = isInsideBox(editor);

        if (
          boxNode !== false &&
          selection instanceof TextSelection &&
          $from.end() === $from.pos
        ) {
         console.log("囲みのなかでEnterが押された、かつカーソルが段落の中間にある");

         return false
        }

        return false
      }
    }
  }

ここまででバグが発生する条件が絞り込めました。次は根本的にバグを修正する必要があります。実装自体は一行で修正できて次のようになります。

addKeyboardShortcuts() {
    return {
      Enter({editor}) {
        const {selection} = editor.state
        const {$from} = selection

        const boxNode = isInsideBox(editor);

        if (
          boxNode !== false &&
          /* テキストの最後でEnterが押された時 */
          selection instanceof TextSelection &&
          $from.end() === $from.pos
        ) {
         editor.chain().setTextSelection(selection.from + 1).run();

         return false
        }

        return false
      }
    }
  }

カーソルの位置を1だけずらしてfalseを返しています。これによってカーソルをコンテンツの右側に1だけ移動させてからEnterの処理がされるようになります。

setTextSelectionpositionを渡すことで任意のテキスト位置までカーソルを移動させられます。positionはTiptapのコアであるProseMirrorの基本的な考え方で、エディター内のコンテンツ(ドキュメント)から任意のNodeを番号で割り出せる・指定できるようにしたものです。詳しくはProseMirror guideのIndexingで説明されています。

バグが発生した原因としては、段落テキストの最後の文字で改行がされていたからでした。以下に表示上見えているカーソルの位置とTiptap上でpositionがどのような位置付けになっているかを示します。以下の例では囲みの中にある空の段落(<p></p>)でテキストがない状態です。この場合も0文字目が段落テキストの最後の文字としてカウントされるためバグが発生します。

<div class="pr-box"><p>テキスト</p><p>|</p><p>テキスト</p></div>

空の段落内にカーソルがありますが、これをpositionに換算すると8になります。バグはposition8の時にEnterされると発生し、setTextSelection(selection.from + 1)position9にしてからEnterされるように修正しました。position9にした場合、カーソルの位置は以下のようになります。

<div class="pr-box"><p>テキスト</p><p></p>|<p>テキスト</p></div>

バグが発生したのはEnterによりpタグの中にpタグを挿入しようとしたためです。pタグはParagraphExtensionで定義されている通り子要素にinlineのNodeしか持てません。pタグ自体はblock要素であるため、Enterが押されてposition8の位置(pタグの内側)にpタグが挿入されてバグの挙動が発生しました。position+1することでparagraphを子要素に持てるdiv.pr-box(BoxExtension)の子要素として、Enterによるpタグが挿入されるようになりました。

ちなみにpositionの数え方はHTMLの開始タグで+1、終了タグで+1、テキスト一文字あたり+1になります。上記の囲みでバグが発生していた例だと「divの開始タグ・pタグの開始タグ・テ・キ・ス・ト・pタグの終了タグ・pタグの開始タグ」でpositionが8のところにカーソルがあったことになります。positionの数え方は基本的な部分で非常に重要ですがProseMirror guide が少し分かりにくいため紹介しました。デバックするときはeditor.getHTML()で最終的なHTML出力が確認できるため、editor.selection.fromなどと合わせてログに出力しておくと便利な場合があります。

リスト機能

リスト機能は主に人物の画像と参考情報をセットでプレスリリースに載せられる機能で、セミナーの登壇者など人物を紹介する時に使えます。機能要件を以下に並べます。

  • 画像のプレースホルダーから画像がアップロードできる
  • 画像をアップロード後に置き換えや削除ができる
  • ボタン・+ ボタンを押して、最小一つ・最大四つまで画像を並べることができる
  • 氏名と肩書を入力できる

実装

リスト機能では、画像アップロードなどを行う動線の装飾を実装する必要があるのと、氏名と肩書きをエディター本文の内容として直接書き込めるようにする必要があります。これは複数のExtensionの組み合わせとNode viewsを使用して実装しました。

リスト機能は以下のように5つのExtensionを組み合わせています。

  • 一番外側にある個々のリストアイテムをラップするMemberListExtension
  • 画像・氏名・肩書をラップするMemberListItemWrapperExtension
  • 画像を表示するMemberListImageExtension
  • 氏名を入力するMemberListNameExtension
  • 肩書を入力するMemberListDescriptionExtension

リスト機能の最終的なアウトプットとして出力するHTMLを以下に示します。リスト機能は、- ボタン・+ ボタンを押して、最小一つ・最大四つまで画像を並べるができるため、div.pr-list__itemは表示するリスト件数によって増減します。

<div class="pr-list">
	<div class="pr-list__item">
		<div class="pr-list__item__img">
			<img src="">
		</div>
		<p class="pr-list__item__name"></p>
		<p class="pr-list__item__position"></p>
	</div>
</div>

MemberListExtension

リスト機能全体をラップする一番外側のExtensionです。<div class="pr-list"></div>をパース・出力する責務を持ちます。contentpr_member_list_item_wrapper{1,4} を指定し、この後に記述するMemberListItemWrapperExtensionが 1 ~ 4つまでしか入らないようにしています。schemaの指定についてはProseMirror Guideに記載されています。

export const MemberListExtension = Node.create({
  name: 'pr_member_list',
  group: 'block',
  content: 'pr_member_list_item_wrapper{1,4}',
  
  ...
  
  parseHTML() {
    return [
      {
        tag: '.pr-list',
      },
    ];
  },
  renderHTML() {
    return ['div', {class: 'pr-list'}, 0];
  },

  ...

});

MemberListItemWrapperExtension

画像・氏名・肩書をラップするExtensionです。<div class="pr-list__item"></div>をパース・出力する責務を持ちます。

export const MemberListItemWrapperExtension = Node.create({
  name: 'pr_member_list_item_wrapper',
  group: 'pr_member_list',
  content:
    '(pr_member_list_image{1} pr_member_list_name{1} pr_member_list_description{1})',

  ...
  
  parseHTML() {
    return [
      {
        tag: '.pr-list__item',
      },
    ];
  },
  renderHTML() {
    return ['div', {class: 'pr-list__item'}, 0];
  },
  addNodeView() {
    return ReactNodeViewRenderer(MemberListItemWrapperNodeView);
  },
});

MemberListImageExtension

画像を表示するExtensionです。NodeViewによる画像のアップロードや<div class="pr-list__item__img"><img src=""></div> をパース・出力する責務を持ちます。例は記述しませんが、getAttrsで指定されているgetMemberListImageNodeAttributes ではparseHTMLで取得できない子要素の属性を取得します。

export const MemberListImageExtension = Node.create({
  name: 'pr_member_list_image',
  group: 'pr_member_list_item_wrapper',
  content: 'block*',

  ...

  parseHTML() {
    return [
      {
        tag: '.pr-list__item__img',
        getAttrs: getMemberListImageNodeAttributes,
      },
    ];
  },
  renderHTML({HTMLAttributes}) {
    const {
      class: wrapperDivClass,
      src,
      imageFileName,
      imageFileNameS3,
      imageClassName,
    } = HTMLAttributes as MemberListImageAttributes;

    const imageAttrs = {
      src,
      'data-fileName': imageFileName,
      'data-fileNameS3': imageFileNameS3,
      class: imageClassName,
    };

    return [
      'div',
      {
        class: wrapperDivClass,
      },
      ['img', mergeAttributes({...imageAttrs})],
    ];
  },
  addNodeView() {
    return ReactNodeViewRenderer(MemberListImageNodeView);
  },
};

MemberListNameExtensionとMemberListDescriptionExtension

それぞれ名前と肩書を入力するExtensionで、<p class="pr-list__item__name"></p><p class="pr-list__item__position"></p>をパース・出力する責務を持ちます。contentinline*としているためエディター上に直接文字を入力できる欄を設置できます。

export const MemberListNameExtension = Node.create({
  name: 'pr_member_list_name',
  group: 'pr_member_list_item_wrapper',
  content: 'inline*',

	...

  parseHTML() {
    return [
      {
        tag: 'p.pr-list__item__name',
        priority: 51,
      },
    ];
  },
  renderHTML() {
    return [
      'p',
      {
        class: 'pr-list__item__name',
      },
      0,
    ];
  },
});


export const MemberListDescriptionExtension = Node.create({
  name: 'pr_member_list_description',
  group: 'pr_member_list_item_wrapper',
  content: 'inline*',

	...
	
  parseHTML() {
    return [
      {
        tag: 'p.pr-list__item__position',
        priority: 51,
      },
    ];
  },
  renderHTML() {
    return ['p', {class: 'pr-list__item__position'}, 0];
  },
});

Extensionを組み合わせて一つの機能を表現する場合、その機能でアウトプットとして出力するHTML構造の整合性を保つ必要があります。例えば、リスト機能では一番親のdivタグの中に全く関係のないspanタグが入ってくることは期待していません。もし全く関係ないHTMLが入ってきて期待していないHTMLを出力した場合、エディター上での見た目が大きく崩れる・DBに壊れたHTMLが保存されてエディター以外のページで表示する時に見た目が崩れるなどのバグを作ってしまいます。意外とドラッグ&ドロップやコピー&ペーストで意図していないHTMLがその機能で表現したいHTML構造を壊すことが多いため、contentによるschemaの制御は厳密に行うのが安全です。

プレースホルダー

PR TIMESのエディターでは各エディター機能によって空欄のテキストに表示しているプレースホルダーを変えています。大画像機能では「キャプションを入力…」、リスト機能では「氏名を入力…」「肩書を入力…」、画像+見出し/テキスト機能では「見出しを入力…」「テキストを入力…」のように変えています。

実装

Tiptapでは公式からPlaceholderExtensionが提供されており、これをベースに実装をしています。PlaceholderExtensionはオプションでプレースホルダーの挙動を設定できて、showOnlyCurrentfalseincludeChildrentrueに設定しています。

showOnlyCurrentは現在カーソルのある位置にだけキャプションを設定した場合はtrueにします。今回は複数のプレースホルダーを同時に表示したいためfalseにしています。以下はtrueにしたときの挙動を示す動画です。

includeChildren は入れ子になったNodeにもプレースホルダーをつけるかどうかを設定します。リスト機能や画像機能のようにExtensionを組み合わせている場合、Nodeが入れ子になるためtrueに設定しています。これをfalseに設定しているとトップレベルのpタグしかプレースホルダーが設定されず、画像のキャプションやリストの氏名などには設定されません。

PlaceholderExtensionは機能的には空文字のNodeに対してis-emptyというclassを付与しています。これと合わせて、公式に載っている例だと以下のようにNodeの種類を判定し、Nodeごとに特定の文言を付与しています。これによってis-emptyが付与されているタグ(空文字を持つタグ)に擬似要素(特定の文言)を付与することでプレースホルダーを表示しています。

/* nodeのtypeごとに設定する文言を指定する */
Placeholder.configure({
  placeholder: ({ node }) => {
    if (node.type.name === 'heading') {
      return 'What’s the title?'
    }

    return 'Can you add some further context?'
  },
})

/* data-placeholder属性にExtensionから設定した文言が設定されるため
      CSSからその文言を擬似要素として空文字のpタグに設定する。 */
.tiptap p.is-empty::before {
  color: #adb5bd;
  content: attr(data-placeholder);
  float: left;
  height: 0;
  pointer-events: none;
}

この公式の方法は、Node viewsを使用していた場合にタグの構造的な問題により使えませんでした。Node viewsはTiptapから出力される最終的なHTMLとは別でエディター上にHTMLをレンダリングできますが、PlaceholderExtensionはNode viewsによってレンダリングされたHTMLに対してis-emptydata-placeholderを付与します。リスト機能の肩書を例にすると以下のようになります。

 /* Node viewsによってレンダリングされたHTML */
<div class="react-renderer node-pr_member_list_description is-empty" data-placeholder="本文を入力">
  <div class="css-1yxrigf-wrapper" data-node-view-wrapper="" style="white-space: normal;">
    <div class="pr-list__item__position" data-node-view-content="" style="white-space: pre-wrap;">
      <div style="white-space: inherit;">
        <br class="ProseMirror-trailingBreak">
      </div>
    </div>
  </div>
</div>

/* Tiptapのアウトプット */
<p class="pr-list__item__position"></p>

Tiptapのアプトプットである<p class="pr-list__item__position"></p>is-emptyが付与された場合、<p class="pr-list__item__position" class=”is-empty”></p>となり、上記で示したようにp.is-empty::beforeのCSSセレクターからcontent: attr(data-placeholder);data-placeholderを参照できます。data-placeholderはNodeのtypeによって変えることができていたため、Node viewsを使わない場合は公式に載っている例で任意の文言が設定できました。

しかし、Node viewsを使っている場合は上記の通りHTMLの構造が変わってしまい、一番深い子要素からdata-placeholderの値を参照できずに任意の文言を設定することができません。

そのため以下のようにCSSから直接文言を設定して対応をしています。

.node-pr_member_list_description.is-empty {
  .pr-list__item__position {
    > div::before {
      display: flex;
      justify-content: center;
      color: gray;
      content: '肩書を入力';
      height: 0;
      pointer-events: none;
      font-size: 13px;
      font-weight: 300;
    }
  }
}

これでNode viewsを使用していた場合も任意の文言が設定できるようになりました。

また、以下のように本文中のカーソルがある部分にはプレースホルダーを追従させたい、囲みや表の中ではプレースホルダーを表示しない、という仕様があったため、公式のPlaceholderExtensionを拡張しました。

公式のPlaceholderExtensionの実装を見ると以下のようになっていてポイントは次の通りです。

  • doc.descendantsでドキュメント(エディター内のコンテンツ)上に存在する全てのNodeを走査する
  • 空文字のNodeに対して、Decoration.nodeis-emptydata-placeholderクラスを付与するdecorationを作成する
  • DecorationSet.create(doc, decorations)で作成したdecorationをドキュメント上に適用する

これで空文字のNodeにis-emptyが付与されてCSSセレクターからプレースホルダーのスタイルを当てられるようなっています。

  addProseMirrorPlugins() {
    return [
      new Plugin({
        key: new PluginKey('placeholder'),
        props: {
          decorations: ({doc, selection}) => {
          
					...

            doc.descendants((node, pos) => {
              const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
              const isEmpty = !node.isLeaf && isNodeEmpty(node);

              if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
                const classes = [this.options.emptyNodeClass];

                if (isEmptyDoc) {
                  classes.push(this.options.emptyEditorClass);
                }

                const decoration = Decoration.node(pos, pos + node.nodeSize, {
                  class: classes.join(' '),
                  'data-placeholder':
                    typeof this.options.placeholder === 'function'
                      ? this.options.placeholder({
                          editor: this.editor,
                          node,
                          pos,
                          hasAnchor,
                        })
                      : this.options.placeholder,
                });

                decorations.push(decoration);
              }

              return this.options.includeChildren;
            });

            return DecorationSet.create(doc, decorations);
          },
        },
      }),
    ];
  },

公式のこの実装と合わせて、本文中のカーソルがある部分にプレースホルダーを追従させるために、return DecorationSet.create(doc, decorations)する前に以下のようなdecorationを付与するようにしています。

if (
  selection instanceof TextSelection &&
  selection?.$cursor !== null &&
  selection?.$cursor.parent?.isTextblock &&
  selection.$cursor.parent.content.size === 0 &&
  // 表、囲み、画像+テキストの中は表示しない
  !isInsideBox(selection) &&
  !isInTable(selection) &&
  !isInside2ColWithHeader(selection) &&
  !isInside2ColNoHeader(selection)
) {
  const from = selection.$cursor.before();
  const to = selection.$cursor.after();

  const decoration = Decoration.node(from, to, {
    class: 'pr-selected-empty-paragraph',
  });
  decorations.push(decoration);
}

空文字のNodeかつTextSelectionであるかをチェックしています。SelectionにはNodeSelectionとTextSelectionの2種類があり、文章中でテキストに対してカーソルがあることを判定したいためTextSelectionをチェックしています。isInsideBoxなどは囲み機能の節で前述したようにカーソルが特定のNode内にあるかどうかをチェックしています。

TextSelectionの場合、selection.$cursorを参照することができます。selection.$cursor.before()selection.$cursor.after()でカーソルが合わせられているテキスト(TextNode)の開始位置と終了位置を取得し、その位置情報をもとにpr-selected-empty-paragraphクラスを付与するdecorationを作成しています。

そして以下のようにpr-selected-empty-paragraphをCSSセレクタで指定してカーソルが合わせらている空のテキストにプレースホルダーが追従するようします。

p.is-empty.pr-selected-empty-paragraph::before {
  color: gray;
  content: attr(data-placeholder);
  float: left;
  height: 0;
  pointer-events: none;
}

まとめ

Tiptapは公式から標準的なエディター機能を提供しています。基本的な機能は公式のExtensionを使うことで簡単に実装できる一方で、TiptapのExtensionとProseMirrorのAPIにより機能拡張がしやすいです。しかし、エディターという少し特殊な技術やTiptapによるProseMirrorの隠蔽により、日本語でのTiptapによる実践的な機能開発に関する情報が少なく、新エディターの新機能開発は苦労しました。振り返ってみると今回記事の中で紹介した知見はProseMirrorの基本ばかりでしたが、これを元に今後もエディター開発を頑張っていきます。またこの記事がTiptapでエディター開発をする人の役に立てば幸いです。

  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 23卒 フロントエンドエンジニア

目次