microCMSにHTMLエディタを追加する方法|ハイライト・補完・インデント対応で編集体験を爆上げ!

はじめに

こんにちは、株式会社メンバーズ ルーツカンパニーOpen in new tabの岸本です。

普段は主にmicroCMSを使ったJamstack構成のサイト構築を行っています。microCMSを利用する中で、リッチエディタでは対応しきれない表現をしたい場面が出てきました。例えば、画像の横並びや<picture>タグを使用した画像の出し分けなど、より細かなHTMLの制御が求められる場面です。それらの機能はリッチエディタには現在ありません。

このようなニーズに対して、microCMS公式ブログでは「テキストエリアに直接HTMLを入力する方法」が紹介されています。

この方法を使えば、リッチエディタでは対応できないHTMLも記述できます。ただし、テキストエリアではシンタックスハイライトやコード補完、インデントといった機能がなく、編集体験としては物足りなさを感じます。

そこで今回は、microCMSの「拡張フィールド」機能と、Monaco Editorを活用し、快適なHTML編集が可能な自作エディタの導入方法をご紹介します。

microCMSの拡張フィールドでHTMLエディタを作成する方法

microCMSには、拡張フィールドというフィールドを自分で作成できる機能があります。

今回はこれを利用してHTMLエディタを作成します。

拡張フィールドは、自作のフィールド用ページを作り、そのページiframeとして埋め込みmicroCMSと通信することで自作のフィールドが作れる機能になります。

iframe内のフィールド用ページとmicroCMSは、window.postMessage APIを使用して値をやりとりしたり、フィールドの高さ設定などを行うことができます。

Monaco Editorとは?

HTMLエディタを自作すると言っても、エディタを自分で開発するのはかなり大変です。そこで、今回はMicrosoftが提供するVSCodeのエディタ部分のみを提供するライブラリ「Monaco Editor」Open in new tabを使用します。

このライブラリを使うことで簡単にブラウザ上で動作するエディタを実装できます。特にカスタマイズせずとも、デフォルトでHTMLやJavaScriptの簡易的なハイライトや補完をしてくれます。

Monaco Editorは主要ブラウザに対応していますが、モバイルはサポート外となっている点に注意が必要です。
今回はmicroCMSをモバイル端末上から操作することはあまりないと考え、Monaco Editorで実装します。

microCMSにHTMLエディタを実装する方法

拡張フィールドとMonaco Editorを使用してmicroCMS上にHTMLエディタを実装していきます。

環境

今回使用する環境は以下の通りです。

  • Node.js: v22.14.0
  • npm: v10.9.2
  • Astro: v5.5.5
  • Monaco Editor: v0.52.2
  • microCMS

Astroは静的サイト生成を基本としたフレームワークです。生成されたHTMLをビルド時に削除することで、高速なWEBサイトを作成することができます。

今回はAstroで作成しますが、iframeで埋め込みさえできれば実装はできるので、特定のフレームワークに縛られず実装が可能です。

AstroでMonaco Editorのページを作成

src/pages/extensionsフォルダを作成し、その中にmonaco-editor.astroを作成しました。

---
// src/pages/extensions/monaco-editor.astro
---

<html>
  <head>
    <title>monaco-editor</title>
    <meta charset="UTF-8" />
    <meta name="robots" content="noindex" />
  </head>
  <body>
    <div id="container"></div>
  </body>
</html>

<style>
  html,
  body {
    width: 100%;
    height: 100%;
  }

  #container {
    border-radius: 7px;
    overflow: hidden;
    height: 100%;
  }
</style>

<script>
  import * as monaco from 'monaco-editor'
  import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
  import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
  import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
  import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
  import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'

  // Workerの設定(コード補完などに必要)
  self.MonacoEnvironment = {
    getWorker(_, label) {
      if (label === 'json') {
        return new jsonWorker()
      }
      if (label === 'css') {
        return new cssWorker()
      }
      if (label === 'html') {
        return new htmlWorker()
      }
      if (label === 'javascript') {
        return new tsWorker()
      }
      return new editorWorker()
    }
  }

  const container = document.querySelector<HTMLDivElement>('#container')
  let editor: null | monaco.editor.IStandaloneCodeEditor = null
  if (container !== null) {
    editor = monaco.editor.create(container, {
      language: 'html', // 言語はHTML
      lineNumbers: 'on', // 行数表示
      roundedSelection: true, // 選択範囲を丸める
      scrollBeyondLastLine: false, // 最後の行を超えてスクロールしない
      readOnly: false, // 読み取り専用にしない
      theme: 'vs-dark', // vs-darkテーマを設定
      insertSpaces: true, // タブでスペースを挿入
      minimap: {
        enabled: false // ミニマップを非表示
      }
    })
  }

  let timerId: NodeJS.Timeout | null = null
  let windowId: string | null = null
  if (editor !== null) {
    const model = editor.getModel()
    if (model !== null) {
      model.onDidChangeContent(() => {
        if (timerId !== null) {
          clearTimeout(timerId)
        }

        // 500ms文字入力がなければmicroCMSに入力データを送信
        // 1文字ごとに連続して送信することで送信順序が入れ替わることを懸念して500msごとに送信するようにしています。(必要ないかもしれません)
        timerId = setTimeout(() => {
          window.parent.postMessage(
            {
              id: windowId,
              action: 'MICROCMS_POST_DATA',
              message: {
                description: editor.getValue().replaceAll(/\s/g, ''),
                data: {
                  code: editor.getValue()
                }
              }
            },
            import.meta.env.PUBLIC_MICROCMS_ORIGIN
          )
        }, 500)

        // 入力ごとにエディタの高さを計算する
        // 19はMonaco Editorのデフォルトレイアウトの1行の高さ
        const monacoLineHeight = 19

        // 最高30行、最低10行で高さを変動させる
        const editorHeight = Math.min(Math.max(10, model.getLineCount()), 30) * monacoLineHeight
        resize(editorHeight)
      })
    }
  }

  // ページのリサイズを検知してエディタをレイアウト
  window.addEventListener('resize', () => {
    if (editor !== null) {
      editor.layout()
    }
  })

  // microCMS上のフィールドのリサイズ処理
  const resize = (height: number) => {
    window.parent.postMessage(
      {
        id: windowId,
        action: 'MICROCMS_UPDATE_STYLE',
        message: {
          height: height,
          width: '100%'
        }
      },
      import.meta.env.PUBLIC_MICROCMS_ORIGIN
    )
  }

  // microCMS上のフィールドの初期値設定
  window.addEventListener('message', (e) => {
    if (e.isTrusted === true && e.data.action === 'MICROCMS_GET_DEFAULT_DATA') {
      windowId = String(e.data.id ?? '')

      // 保存された入力値があれば設定
      const data = e.data?.message?.data?.code ?? ''
      if (editor !== null) {
        editor.setValue(data)
      }

      // 初期の高さ設定
      resize(190)
    }
  })
</script>

Monaco Editorが埋め込まれるための要素 <div id="container"></div> をbodyの中に入れています。
また、microCMSの拡張フィールドとしてしか使わないため、Google検索などにヒットしないようにnoindexを設定しています。

<head>
  <title>monaco-editor</title>
  <meta charset="UTF-8" />
  <meta name="robots" content="noindex" />
</head>
<body>
  <div id="container"></div>
</body>

スタイルについてはエディタのみを表示するため、ページ全体にエディタが広がるように設定しています。

html,
body {
  width: 100%;
  height: 100%;
}

#container {
  border-radius: 7px;
  overflow: hidden;
  height: 100%;
}

monaco.editor.createで#containerの要素を指定することで、Monaco Editorをレンダリングできます。その他の設定などについては、公式ドキュメントOpen in new tabをご確認ください。

const container = document.querySelector<HTMLDivElement>('#container')
let editor: null | monaco.editor.IStandaloneCodeEditor = null
if (container !== null) {
  editor = monaco.editor.create(container, {
    language: 'html', // 言語はHTML
    lineNumbers: 'on', // 行数表示
    roundedSelection: true, // 選択範囲を丸める
    scrollBeyondLastLine: false, // 最後の行を超えてスクロールしない
    readOnly: false, // 読み取り専用にしない
    theme: 'vs-dark', // vs-darkテーマを設定
    insertSpaces: true, // タブでスペースを挿入
    minimap: {
      enabled: false // ミニマップを非表示
    }
  })
}

Workerの設定をすることでコード補完などを実装することができます。Reactの例ですが、公式のサンプルOpen in new tabが公開されているのでそれを参考に実装しました。

// Workerの設定(コード補完などに必要)
self.MonacoEnvironment = {
  getWorker(_, label) {
    if (label === 'json') {
      return new jsonWorker()
    }
    if (label === 'css') {
      return new cssWorker()
    }
    if (label === 'html') {
      return new htmlWorker()
    }
    if (label === 'javascript') {
      return new tsWorker()
    }
    return new editorWorker()
  }
}

これで開発サーバーを起動してページを確認するとVSCodeのような見た目のMonaco Editorが動くことを確認できます。

HTMLの補完やハイライトなどもしてくれています。
問題なければデプロイします。

microCMS上に作成したHTMLエディタ(Monaco)のフィールドを設定

次にデプロイしたエディタのページをmicroCMS上のフィールドに設定します。

今回はフィールドIDに「editor」、表示名を「HTMLエディタ」としました。

種類で「拡張フィールド」を選択します。

読み込み先URLにデプロイ後のページを記載します。

実際にmicroCMSのコンテンツ編集画面で追加した拡張フィールドを確認すると、エディタが表示されました。

フロントエンドでmicroCMSのHTMLエディタ(Monaco)のレスポンスを表示する処理を作成

フロントエンド上にHTMLエディタの内容を表示します。普通のフィールドとあまり変わる部分はありません。

microCMS側に送るデータの処理は以下のようにしています。

window.parent.postMessage(
  {
    id: windowId,
    action: 'MICROCMS_POST_DATA',
    message: {
      description: editor.getValue().replaceAll(/\s/g, ''),
      data: {
        code: editor.getValue()
      }
    }
  },
  import.meta.env.PUBLIC_MICROCMS_ORIGIN
)

そのため、今回の実装の場合拡張フィールドのレスポンスは以下のようになります。

{
  "editor": {
    "code": "<p>HTMLエディタテスト</p>"
  }
}

レスポンスがHTML文字列なので、エスケープせずに埋め込む必要があります。
Astroで実装する場合はset:htmlOpen in new tabを使用してHTML文字列を挿入することでHTMLをエスケープせずにそのまま表示可能です。

---
const content = await getContent() // コンテンツ取得処理
---

<div set:html={content.editor.code} />

実際使用する際は、リッチエディタなどと合わせて使うことになると思うのでフィールドIDによる分岐処理なども必要になるかと思います。

microCMS + Monaco Editorでの開発工夫ポイントまとめ

ミニマップのオフ

今回は広くエディタを見せるためにミニマップを非表示にしていますが、プロジェクトに合わせてカスタマイズしてみてください。

editor = monaco.editor.create(container, {
  language: 'html', // 言語はHTML
  lineNumbers: 'on', // 行数表示
  roundedSelection: true, // 選択範囲を丸める
  scrollBeyondLastLine: false, // 最後の行を超えてスクロールしない
  readOnly: false, // 読み取り専用にしない
  theme: 'vs-dark', // vs-darkテーマを設定
  insertSpaces: true, // タブでスペースを挿入
  minimap: {
    enabled: false // ミニマップを非表示
  }
})

コードの改行に合わせて自動で高さを広げる

改行ごとに自動で高さを調節するようにしています。
microCMSのリッチエディタと似たような動作にしており、最高30行、最低10行の範囲で自動調節しています。

// 入力ごとにエディタの高さを計算する
// 19はMonaco Editorのデフォルトレイアウトの1行の高さ
const monacoLineHeight = 19

// 最高30行、最低10行で高さを変動させる
const editorHeight = Math.min(Math.max(10, model.getLineCount()), 30) * monacoLineHeight
resize(editorHeight)

実際に動作している様子


500ミリ秒入力されなかったらmicroCMSに入力データを送信

1文字ごとにpostMessageでmicroCMSに文字データを送信するのではなく、文字入力が落ち着いてから500ミリ秒後に送信するようにしています。1文字ごとに連続して送信することで送信順序が入れ替わることを懸念した処理なのですが、未検証のため必要ない可能性があります。

model.onDidChangeContent(() => {
  if (timerId !== null) {
    clearTimeout(timerId)
  }

  // 500ms文字入力がなければmicroCMSに入力データを送信
  // 1文字ごとに連続して送信することで送信順序が入れ替わることを懸念して500msごとに送信するようにしています。(必要ないかもしれません)
  timerId = setTimeout(() => {
    window.parent.postMessage(
      {
        id: windowId,
        action: 'MICROCMS_POST_DATA',
        message: {
          description: editor.getValue().replaceAll(/\s/g, ''),
          data: {
            code: editor.getValue()
          }
        }
       },
       import.meta.env.PUBLIC_MICROCMS_ORIGIN
    )
  }, 500)

まとめ:microCMSに拡張HTMLエディタを導入するメリット

今回はmicroCMS上に拡張フィールドとMonaco Editorを使ってHTMLエディタを実装する方法を紹介しました。

既存のテキストエリアをHTMLエディタとして使用する場合と比べると、ハイライトや補完を行ってくれるためかなり使いやすくなりました。
VSCodeの拡張機能などは使用できないというデメリットはありますが、高度なコーディングをしなければ問題ないかと思います。

この記事が拡張フィールド開発の参考になれば幸いです。

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

岸本 彬
岸本 彬
2021年にメンバーズに入社。現在はメンバーズルーツカンパニーにて主にフロントエンドエンジニアとして、microCMSとAstroを使ったJamstack構成の開発を行っています。
詳しく見る
ページトップへ戻る