Tiptapでメールマガジン用のHTMLメールエディターを作った

はじめに

この記事はアソビュー! Advent Calendar 2024 の8日目(裏面)です。
こんにちはアソビューの井上です。
最近同僚に焚き付けられて勢いでkeychronの分割キーボードに入門し、magic trackpadを間に置いたところ、もう普通のキーボードとマウスに戻れない体になってしまいましたが、皆さんはいかがお過ごしでしょう。
今回は、Tiptapというエディターフレームワークを活用して、HTMLメールを効率よく作成できるメルマガ用エディターを開発した事例について紹介します。

Tiptapとは

tiptap.dev

Tiptapは、ヘッドレスで拡張性の高いエディターフレームワークです。
これを使うと、直感的なUIでコンテンツを編集できるエディターを実現することができます。内部的にはProseMirrorというエディターライブラリをベースにしています。

今回のメールマガジン配信機能は既存の社内向けの管理システムの一機能として実装しました。
この社内管理システムはUIコンポーネントライブラリのMantineを活用しています。
そのため、今回の選定においてもMantineのRichTextEditor(内部的にはTiptapをラップしている)が候補になり、調査した結果拡張性なども後述する今回の要件に当てはまりそうであったためTiptap(RichTextEditor)を選定しました。

メールマガジン配信内製化プロジェクトについて

プロジェクトの概要を軽く説明します。

背景と特徴

メールマガジンはアソビューのお得な情報をユーザーに届ける重要なタッチポイントです。

これまで外部SaaSを利用していたメルマガ配信の運用を内製化し、AWSのSESを活用したシステムに移行しました。インフラ面の詳細については、下記のブログをご覧ください。

tech.asoview.co.jp

プロジェクトのポイントは下記の通りです。

  • 短納期開発: 約3ヶ月というタイトなスケジュールで開発を完了
  • 大規模配信: 数百万人にメールを送信する規模に対応
  • 高い影響度: メルマガはビジネスの重要な接点であり、失敗の許されない領域
  • 既存運用からの移行:すでにメルマガ作成、配信運用されているため、移行前後で運用負荷やメールの質が変化してはいけない。

本ブログでは主にメルマガ作成運用に関わるエディター部分にフォーカスして紹介します。

Tiptapを使ったHTMLメール作成機能の実装

機能要件について

今回は外部SaaSからの移行であり、実際メールマガジンの作成を行っていたマーケティング担当者がそちらの操作に習熟しているため、機能や使い勝手はなるべく踏襲する必要がありました。

とはいえ、元々使っていたSaaSのアプリケーションは専用で開発されたアプリケーションであり、とても高機能のため短期間でそれを全て網羅することは現実的ではありません。

そのため、マーケティング担当者にヒヤリングすることで、必要な機能(MVP)を把握し、少なくとも初期リリースはMUST要件に専念し、WANT要件についてはファーストリリース後に拡張する前提で開発を始めました。

以下が主に必要であった機能要件です。

エディター、プレビュー機能

HTMLメールをGUIで容易に作成でき、実際の表示を即座にプレビューするようにできる必要があります。

保存、編集とHTML出力機能

最終的には配信するためにHTMLとして出力する必要がありますが、保存や一度保存したあとの再編集は自由に行える必要があります。

エクステンションの拡張性

画像やボタン、リンクなど様々な形式のコンテンツを組み合わせてHTMLメールを構築し、今後も新しいコンテンツが追加できる必要があります。

構成要素について

編集パネルとエディター画面

元々利用していた外部SaaSのUIに近いかたちにするために、このように左パネル:プレビューエリア、右パネル:編集エリア、といった構成にしています。

プレビューエリアと編集エリアで左右に分かれています

このように左パネルと右パネルで別れており、左は実際の配信されるHTMLと同等のUIが確認できるプレビューエリア、右はコンテンツを選んで入力する入力エリアという役割になっています。
右でコンテンツを選択し、入力して確定すると左のエリアに反映される流れです。
情報の流れを簡潔にするため、テキストも含めて設定項目は全て右の編集パネルで入力して確定→右パネルへ反映という設計にしました。(左パネルでWYSIWYG形式で文章の直接編集はできません。)

このようにuseEditorから取得したeditorのインスタンスを右パネルに渡しています。

import { JSONContent, useEditor } from '@Tiptap/react'

const editor = useEditor({ /* 省略 */ }) 

<RichTextEditor editor={editor}>
   <RichTextEditor.Content maw={500} mih={300} /> // 左パネルのエディター
</RichTextEditor>
<EditController editor={editor} /> // 右パネルのコントローラー

カスタムエクステンションの活用

Tiptapの最大の魅力はカスタムエクステンションを使ったカスタマイズ性です。
既存のエクステンションを拡張することもできますが、今回は要件に従って新規のカスタムエクステンションを作成することにしました。

tiptap.dev

今回は以下のようなカスタムエクステンションを開発しました。

  • CustomText: 文字色、背景色やフォントサイズを指定して文章を設定できる。
  • Image: 画像のサイズやパディングを柔軟に調整、リンクを設定できる。
  • SplitImage: 画像を二枚、横並びに。それぞれにリンクも設定できる。
  • LinkText: リンクテキスト。パラグラフの途中に差し込むことができる。
  • LinkTextBlock: ブロック要素のリンクテキスト
  • LinkButton: 画像のサイズやパディングを柔軟に調整できる。
  • Divider: 罫線

これらを組み合わせることでこれまで通りのレイアウトでメールマガジンを作成することができます。

const editor = useEditor({
  extensions: [
    StarterKit,
    CustomTextNode,
    LinkTextNode,
    LinkButtonNode,
    ImageInputNode,
    ImageSplitContentNode,
    DividerNode,
    BlockLinkNode,
  ],
})

様々なコンテンツ

カスタムエクステンションの定義について

tiptap.dev

個々のカスタムエクステンションでは主に下記の項目を設定していました。

// カスタムエクステンションの一つであるCustomTextNodeの定義の抜粋
export const CustomTextNode = Node.create({
 name: 'customText',
 group: 'block',
 inline: false,
 atom: true,
 parseHTML() {
   return [
    {tag: 'customText'},
   ]
 },

 addAttributes() {
   return {
     id: {
       default: null,
     },
     text: {
       default: '',
     },
     lineHeight: {
       default: '',
     },
     // 以下必要な設定項目を定義
   }
 },
 renderHTML({ HTMLAttributes }) {
   const {
      align,
      color,
      fontSize,
      bold,
      text,
      lineHeight,
      backgroundColor,
      border,
      borderColor,
      paddingTop,
      paddingBottom,
      paddingLeft,
      paddingRight,
   } = HTMLAttributes
  const container = document.createElement('div')
   // DOMを生成して上述のHTMLAttributesを使って肉付けしてreturnします。地道な作業です。。
   // 下記はその実装の一部です
   
    if (border) {
      container.style.border = 'solid 1px'
      if (borderColor) {
        container.style.borderColor = borderColor
      }
    }

    const paragraphStyle = `
      color: ${color};
      font-size: ${fontSize};
      font-weight: ${bold ? 'bold' : 'normal'};
      margin: 0;
    `

    const paragraph = document.createElement('p')

    // HTMLメールのためCSSはインラインで定義する必要があります
    paragraph.style.cssText = paragraphStyle


    const textWithLineBreaks = text.split('\n').join('<br />')
    paragraph.innerHTML = textWithLineBreaks

    container.appendChild(paragraph)


   return container
 },


 addNodeView() {
   return ReactNodeViewRenderer(Component)
   //return ({ node, getPos }) => {
   //  const container = document.createElement('div')
   // DOMを直接操作することもできます。
   //  return {
   //    dom: container,
   //  }
   }
 },
})

addAttributes

カスタムエクステンションに変数として渡す値をここで初期化します。初期化しない限り、addNodeViewおよびrenderHTMLで利用できません。

renderHTML

最終的に出力されるHTMLの定義です。attributeを使い、複雑な構造の場合はDOMを地道に構築してreturnします。 ※HTMLメールで表示するためにはインラインスタイルでCSSを定義する必要があります

addNodeView

プレビューエリアに表示されるDOMの定義をします。ここではReactを使うこともでき、プレビューをリッチで柔軟なUIにすることも可能です。
今回は最低限のUIで実装し、コンテンツ選択時のフォーカス時のインタラクションなど一部描画のみ実現しました。

addNodeViewで左パネルのプレビュー表示、renderHTMLで実際出力されるHTMLをそれぞれ別々に定義できます。
後述しますが、HTMLはtableタグで組む必要がありますが、プレビューの構造はその限りではなく、運用効率を上げるためのインタラクション(フォーカスやコピーボタン)などプレビューでのみ必要な表示制御があるため、これらを作り分けられることが重宝しました。

DOM構造の二重開発にはなってしまいますが

  • メルマガ運用者が表現したいUIを構築して構造化するためのプレビュー
    →addNodeView
  • 構造化したUIを元にHTMLメールであらゆるメーラーで問題なく表示するためのHTMLレンダリング
    →renderHTML

といった形で役割が明確に分けられるので。本要件には合っており結果的に効率が良かったです。

コンテンツの情報入力パネル

コンテンツの情報を入力してカスタムエクステンションに設定するための入力フォームです。
こちらについては特殊なことはしておらず、mantineの入力系コンポーネント、React Hook Formやzodのスキーマバリデーションを組み合わせて構築しています。
本機能の開発についてはCSSを直接書くことはほぼせずに、mantineのコンポーネントでレイアウトを作ることができました。

情報入力パネル

情報更新と各種インタラクションの実装

editorのイベントをリッスンして各種操作をすることで下記の情報の更新やインタラクションを実現しています。

入力パネルからの情報更新

情報入力で入力した情報でNodeViewを更新

入力パネルのフォームで確定したタイミングでactiveInput.idをキーに登録や更新を行います。
フォームで入力した値をattrsに設定してeditorインスタンスのinsertContentsを実行します。
(insertという名前ですが、更新、登録どちらにも活用します)

  const handleApplyChanges = useCallback<SubmitHandler<CustomTextFormSchema>>(
    (value) => {
      editor
        .chain()
        .focus()
        .insertContent({
          type: 'customText',
          attrs: {
            id: activeInput.id,
            lineHeight: value.lineHeight,
            align: value.align,
            color: value.color,
            backgroundColor: value.backgroundColor,
            fontSize: value.fontSize,
            bold: value.bold,
            text: value.text,
            border: value.border,
            borderColor: value.borderColor,
            paddingTop: value.paddingTop,
            paddingBottom: value.paddingBottom,
            paddingLeft: value.paddingLeft,
            paddingRight: value.paddingRight,
          },
        })
        .run()
    },
    [activeInput.id, editor],
  )

左パネルで選択したコンテンツを右パネルに表示する

左パネルでクリックして選択したコンテンツの情報を右パネルに表示

editorの selectionUpdate イベントのコールバックに関数を設定し、selectionUpdateのタイミングでeditor.stateから現在選択状態のNodeの情報を取得します。そこから現在activeなnodeのIDが取れるため、コンポーネントの状態変更により、選択されたコンテンツの入力画面を表示することができます。

const [inputs, setInputs] = useState<Partial<NodeInput>[]>([])
const [activeNodeId, setActiveNodeId] = useState<string | null>(null)
const [selectedContent, setSelectedContent] = useState<ContentType | null>(null)


useEffect(() => {
    const handleSelectionUpdate = () => {
      const { selection } = editor.state // 選択中のノードを取得
      setActiveNodeId(selection.node.attrs.id) // 選択中のノードのIDをstateに設定
      setSelectedContent(selection.node.type.name as ContentType)  // 選択中のノードの種類をstateに設定

      // ノードビュー上の選択状態を設定するため、ポジションをノードに指定する
      editor.view.dispatch(
        editor.state.tr.setNodeAttribute(selection.from, 'focusPos', selection.from),
      )
      const newInputs: Partial<NodeInput>[] = []

      editor.state.doc.descendants((node) => {
        newInputs.push({ ...node.attrs })
      })
      setInputs(newInputs)
    }
    editor.on('selectionUpdate', handleSelectionUpdate)
}, [editor])

データの取得と保存について

TiptapのエディターのデータはJSONで保存できます。また、逆にJSONのデータをeditorのインスタンスに設定することで状態を復元できます。
作成したメールマガジンデータごとにこのJSON文字列を保存しています。

editorで設定できるコンテンツは下記の型になっており、HTML文字列そのもの(HTMLContent)を設定することもできます。

type Content = HTMLContent | JSONContent | JSONContent[] | null;

export type JSONContent = {
    type?: string;
    attrs?: Record<string, any>;
    content?: JSONContent[];
    marks?: {
        type: string;
        attrs?: Record<string, any>;
        [key: string]: any;
    }[];
    text?: string;
    [key: string]: any;
};

今回の機能では構造化されたJSONContentをJSON.stringifyで文字列化して保存、取得時にJSON.parseしてオブジェクトをcontentに設定するようにしました。
下記のようなJSONでメールHTMLの情報が保存されています。

コンテンツのJSON構造

バックエンドのREST APIからコンテンツのデータを取得し、editor.command.setContentで設定します。

const {
    data: contentData,
  } = useSWRGateway<GetContentResponse, GetContentRequest>('/apipath/to/get/mailmagazine-contents/')


useEffect(() => {
    if (contentData?.htmlPartJson) {
      const contentParseData: { content: JSONContent[] } = JSON.parse(contentData.htmlPartJson)
      editor?.commands.setContent(
        contentParseData.content
      )
    }
  }, [contentData?.htmlPartJson, editor])

データの保存時はeditorからeditor.getJSON()で取得したJSONを文字列化して保存します。
配信する際のHTMLも同じタイミングでgetHTML関数で生成し、保存しています

const content: DeliveryContent = {
   name: id,
   mailTitle: title,
   htmlPart: editor?.getHTML(), // 配信用のHTML文字列
   htmlPartJson: JSON.stringify(editor.getJSON()),  // 保存用の構造化されたJSON文字列
}
await updateContent({
  content,
})

以上が、HTMLメールの編集と保存についての説明です。

メーラーごとの差異との戦い

さて、ここまででHTMLの作成と保存を説明しましたが、本当に大変なのは実際のメーラーにて正しく表示されるかです。ここでうまくいかないと意味がありません。
周知の事実ではありますが、HTMLメールはブラウザと異なり、メーラーごとに表示が大きく異なります。そのため、以下の工夫を行いました。

Tableレイアウトの採用

GmailやWebメールについてはその限りではないのですが、Outlookなどの環境では未だにtableレイアウトでないと崩れてしまうことがあります。
モダンなメーラーは対応できているとのことで当初はdivタグで試しに組んでみたのですが、やはりまだまだ現役なOutlookなどにおいては使えないCSS定義も多く、対象の全てのメーラーで安定した表示を実現するため、メール全体をtableレイアウトで構築しました。

Litmusによるデバッグ

Outlookの他にもiOSのメールアプリなどもCSSの解釈にクセがあり苦労しました。それらは実機のアプリケーションを使わない限り確認できないのですが、実機が手元になかったり、有ったとしても一回一回端末実機確認するとかなりの工数を割くことが想定されます。
実機でのテストを効率化するため、HTMLメール作成支援ツールのLitmusを活用し、多数のメーラー環境での表示確認を行いました。

www.litmus.com

litmusを活用するとPC、モバイルやWindows、Mac、Gmail、Outlookなど様々な環境での表示を確かめることができます。 実際にメールを配信せずともHTMLをそのままペーストするだけで確認することもできるためデバッグの効率が上がりました。

開発を通して良かった点と課題

良かった点

Tiptapの拡張性が高く、要件に対して柔軟に対応することができました。
今回はエディター側での更新は基本的にはせず、編集UI(右パネル)のフォームで修正する形式を取りました。
また、それぞれの編集タイプをコンテンツとして分けたことで、更新反映の流れを一方通行にしてシンプルにでき、コンテンツの追加がしやすいような設計にできたと思います。
また、プロジェクトの後半は修正作業を分担して行うことができて効率が上がりました。

ハマったポイント

エディタービューと入力パネルとの連動のためeditorインスタンスから細かい制御が必要になるが、それらはTiptapというよりはベースとなっているProseMirrorの機能になるためドキュメントもそちらを参照しに行く必要があり、実際に連動してデータが取れるかはトライアンドエラーで試す必要がありました。

レガシーな環境のためにTableレイアウトで組んで調整をするもののブラウザによって表示がまちまちなケースがあったこと。 特定の環境のための対応をすると別の環境の表示が崩れるなどにも苦労しました。

まとめ

エディターの開発に関しては過去に何度か見たり開発をしたことがありましたが、往々にしてライブラリの複雑さに振り回されたり、要件に合わないため魔改造のようなことをするケースが有ったように思います。
Tiptapについては柔軟性、拡張性の高さから、そういった問題が生じること無く、短期間での開発を実現することができました。
今回のこのPJと全く同じ作りをすることはあまりないかも知れませんが、誰かがTiptapを使ってやりたいことを実現するための参考に少しでもなればいいと思い、記事に残しました。
弊社でもメールに限らず、リッチなコンテンツを作るための入稿画面が必要になるケースは今後もあると思いますので、その際も活用していけると良いと思います。

さいごに

アソビュー!会員の方はぜひ受信したメールマガジンを確認してみてください。(初めての方はぜひとも会員登録を、、!)
このシステムからの配信メールでアソビュー!のコンテンツを魅力的にご紹介できてるのではないかと思います。(ぜひ予約して遊びに行ってください!)

アソビューでは、一緒に働くメンバーを大募集しています!カジュアル面談も実施しておりますので、少しでもご興味をお持ちいただけましたら、ぜひお気軽にご応募ください!

www.asoview.com