2026年6月2日

2026年6月2日

WordPressでcontenteditableを使ったリッチテキストエディタを実装する方法

HTML の contenteditable 属性を使うと、ライブラリなしでも太字・リンク・箇条書き程度の小さなリッチテキストエディタを WordPress のフロントエンドに組み込めます。ただし、入力された HTML をそのまま保存すると XSS の入り口になるため、「ブラウザ側は編集体験だけを担当し、何を保存してよいかはサーバー側の wp_kses() が決める」という分担が要になります。この記事では、投稿ごとの補足メモを編集権限のあるユーザーだけがその場で書き換えられる、という小さな機能を例に、REST API での保存、nonce と権限チェック、貼り付け処理、日本語入力(IME)への配慮までを順に組み立てます。

先に決めておくこと:自作するか、既存のエディタを使うか

contenteditable は手軽に始められる一方、本格的なエディタを自作するのは大仕事です。書式ボタンの実装に使われてきた document.execCommand() は MDN などで非推奨(obsolete)と扱われていますが、編集コマンドを置き換える標準 API はまだ無く、主要ブラウザでは引き続き動作しています(2026年10月現在)。挙動のブラウザ差もあるため、用途で使い分けるのが現実的です。

  • ブロックエディタ(Gutenberg)の中で使う場合:自作せず、@wordpress/block-editor の RichText コンポーネントを使います。保存形式や元に戻す操作との整合をコアが面倒を見てくれます。
  • 管理画面のメタボックスや設定画面の場合:wp_editor() で従来の TinyMCE エディタを出すのが手早い選択です。
  • フロントエンドで表や画像まで扱う本格的な編集:Quill、Tiptap、Lexical などの保守されているライブラリを検討します。
  • 太字・斜体・リンク・箇条書き程度の軽い編集:この記事のように contenteditable を直接使う方法が向いています。

どの方法を選んでも、保存時のサニタイズと権限チェックは省略できません。以降のコードはその部分を中心に読んでください。

構成の全体像

作るものは次の3つです。いずれも子テーマの functions.php よりも、テーマを切り替えても機能が残る mu-plugin(wp-content/mu-plugins/ 配下)に置くのがおすすめです。本番に入れる前に、ステージング環境で動作を確認し、バックアップを取っておいてください。

  1. 投稿本文の下に、メモ表示用の領域と(編集権限がある人にだけ)ツールバーを出す PHP
  2. 編集内容を受け取り、許可したタグだけに絞って保存する REST API ルート
  3. ツールバー操作・貼り付け・自動保存を担当する JavaScript

PHP:表示・保存・スクリプト読み込み

wp-content/mu-plugins/myrte.php として保存する例です。JavaScript は wp-content/mu-plugins/myrte/editor.js に置きます。

<?php
/**
 * Plugin Name: MyRTE 投稿メモエディタ
 */

// 保存してよいタグと属性(ここに無いものは保存時に取り除かれる)
function myrte_allowed_html() {
	return array(
		'p'      => array(),
		'br'     => array(),
		'strong' => array(),
		'b'      => array(),
		'em'     => array(),
		'i'      => array(),
		'ul'     => array(),
		'ol'     => array(),
		'li'     => array(),
		'a'      => array( 'href' => true ),
	);
}

add_action( 'rest_api_init', function () {
	register_rest_route( 'myrte/v1', '/note/(?P<id>\d+)', array(
		'methods'             => 'POST',
		'callback'            => 'myrte_save_note',
		'permission_callback' => function ( WP_REST_Request $request ) {
			return current_user_can( 'edit_post', (int) $request['id'] );
		},
		'args'                => array(
			'html' => array(
				'type'     => 'string',
				'required' => true,
			),
		),
	) );
} );

function myrte_save_note( WP_REST_Request $request ) {
	$post_id = (int) $request['id'];
	if ( ! get_post( $post_id ) ) {
		return new WP_Error( 'myrte_not_found', '投稿が見つかりません。', array( 'status' => 404 ) );
	}
	$html = wp_kses( (string) $request->get_param( 'html' ), myrte_allowed_html() );
	// update_post_meta() は内部で wp_unslash() するので、先に wp_slash() しておく
	update_post_meta( $post_id, 'myrte_note', wp_slash( $html ) );
	return rest_ensure_response( array( 'html' => $html ) );
}

add_filter( 'the_content', function ( $content ) {
	if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
		return $content;
	}
	$post_id = get_the_ID();
	$note    = wp_kses( (string) get_post_meta( $post_id, 'myrte_note', true ), myrte_allowed_html() );

	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return $note ? $content . '<div class="myrte-note">' . $note . '</div>' : $content;
	}

	$toolbar = '<div class="myrte-toolbar" role="toolbar" aria-label="書式">'
		. '<button type="button" data-cmd="bold">太字</button>'
		. '<button type="button" data-cmd="italic">斜体</button>'
		. '<button type="button" data-cmd="insertUnorderedList">箇条書き</button>'
		. '<button type="button" data-cmd="createLink">リンク</button>'
		. '</div>';
	$editor  = '<div class="myrte-note" contenteditable="true" role="textbox" aria-multiline="true" aria-label="投稿メモ">' . $note . '</div>';
	$status  = '<p class="myrte-status" aria-live="polite"></p>';

	return $content . $toolbar . $editor . $status;
} );

add_action( 'wp_enqueue_scripts', function () {
	if ( ! is_singular() || ! current_user_can( 'edit_post', get_queried_object_id() ) ) {
		return;
	}
	wp_enqueue_script(
		'myrte',
		plugins_url( 'myrte/editor.js', __FILE__ ),
		array(),
		'1.0.0',
		array( 'strategy' => 'defer', 'in_footer' => true )
	);
	wp_add_inline_script( 'myrte', 'const MYRTE = ' . wp_json_encode( array(
		'endpoint' => rest_url( 'myrte/v1/note/' . get_queried_object_id() ),
		'nonce'    => wp_create_nonce( 'wp_rest' ),
	) ) . ';', 'before' );
} );

ポイントは3つあります。まず permission_callback で current_user_can( 'edit_post', ... ) を確認し、その投稿を編集できる人以外は保存できないようにしています。次に、保存時と表示時の両方で wp_kses() を通しています。wp_kses() は href の値も検査し、javascript: のような許可されていないプロトコルを取り除きます。最後に、wp_create_nonce( 'wp_rest' ) で作った nonce を X-WP-Nonce ヘッダーで送ることで、Cookie 認証の REST リクエストとして扱われます。nonce が無いと、ログイン中でも未ログイン扱いになり保存は拒否されます。

wp_enqueue_script() の第5引数に配列で strategy を渡す書き方は WordPress 6.3 で追加されたものです。それより古い環境では true(フッター読み込み)を渡してください。

JavaScript:ツールバー・貼り付け・自動保存

( function () {
	const editor = document.querySelector( '.myrte-note[contenteditable]' );
	const toolbar = document.querySelector( '.myrte-toolbar' );
	const status = document.querySelector( '.myrte-status' );
	if ( ! editor || ! toolbar || typeof MYRTE === 'undefined' ) {
		return;
	}

	// Enter で <div> ではなく <p> を作らせる
	document.execCommand( 'defaultParagraphSeparator', false, 'p' );

	// ボタンを押したときにエディタからフォーカスが外れないようにする
	toolbar.addEventListener( 'mousedown', ( e ) => {
		if ( e.target.closest( 'button' ) ) {
			e.preventDefault();
		}
	} );

	toolbar.addEventListener( 'click', ( e ) => {
		const btn = e.target.closest( 'button[data-cmd]' );
		if ( ! btn ) {
			return;
		}
		const cmd = btn.dataset.cmd;
		if ( cmd === 'createLink' ) {
			const url = window.prompt( 'リンク先のURL(https:// から)' );
			if ( ! url || ! /^https?:\/\//i.test( url ) ) {
				return;
			}
			document.execCommand( 'createLink', false, url );
		} else {
			document.execCommand( cmd, false, null );
		}
		editor.focus();
	} );

	// 貼り付けはプレーンテキストに限定する
	editor.addEventListener( 'paste', ( e ) => {
		e.preventDefault();
		const text = e.clipboardData ? e.clipboardData.getData( 'text/plain' ) : '';
		document.execCommand( 'insertText', false, text );
	} );

	let timer = null;
	const scheduleSave = () => {
		clearTimeout( timer );
		timer = setTimeout( save, 1500 );
	};

	editor.addEventListener( 'input', ( e ) => {
		// 日本語変換中は保存を予約しない
		if ( e.isComposing ) {
			return;
		}
		scheduleSave();
	} );
	editor.addEventListener( 'compositionend', scheduleSave );

	async function save() {
		status.textContent = '保存中…';
		try {
			const res = await fetch( MYRTE.endpoint, {
				method: 'POST',
				credentials: 'same-origin',
				headers: {
					'Content-Type': 'application/json',
					'X-WP-Nonce': MYRTE.nonce,
				},
				body: JSON.stringify( { html: editor.innerHTML } ),
			} );
			if ( ! res.ok ) {
				throw new Error( 'HTTP ' + res.status );
			}
			status.textContent = '保存しました';
		} catch ( err ) {
			status.textContent = '保存できませんでした(' + err.message + ')';
		}
	}
} )();

実装でつまずきやすい点

ボタンを押すと書式が効かない

ツールバーのボタンをクリックすると、フォーカスがボタンへ移り、編集中の選択範囲が失われることがあります。上のコードでは mousedown の既定動作を止めて、フォーカスをエディタに残しています。<button> に type="button" を付けておくのも、フォーム内に置いたときの誤送信を防ぐために大切です。

貼り付けで余計な装飾やスタイルが入る

Word や他のサイトからコピーした内容は、style 属性や <span> が大量に付いた HTML として貼り付けられます。保存時に wp_kses() で取り除かれはしますが、編集中の見た目が崩れるうえ、意図しない構造が残ります。この例では paste イベントで既定動作を止め、text/plain だけを挿入しています。書式を残したい場合は、貼り付けた HTML をサーバー側と同じ許可リストで整形する処理が別途必要になり、手間が一気に増えます。

なお、書式が一切要らない入力欄なら contenteditable="plaintext-only" という値もあり、主要ブラウザで使えるようになっています(2026年10月現在)。古いブラウザへの対応が必要な場合は、対応状況を確認してから採用してください。

日本語入力中に保存や処理が走る

input イベントは IME で変換している最中にも発生します。変換途中の文字列で保存したり、DOM を書き換えたりすると、変換が確定前に途切れることがあります。InputEvent.isComposing が true の間は処理を見送り、compositionend で改めて保存を予約するのが定番の対処です。

「元に戻す」が効かなくなる

execCommand() 経由の変更はブラウザの取り消し履歴に記録されますが、innerHTML を書き換えたり Range で直接ノードを挿入したりすると、その操作は履歴に残らず、Ctrl+Z の挙動が不自然になります。独自の整形処理を入れる場合は、取り消し操作との兼ね合いも確認してください。より細かく制御したくなったら、beforeinput イベントの inputType で操作の種類を判定する方法もありますが、その段階まで来たらライブラリの採用を検討する時期です。

セキュリティと運用の確認ポイント

  • クライアント側の検証は信用しない:JavaScript 側の URL チェックは使い勝手のためのもので、攻撃者は REST API を直接呼べます。許可するタグ・属性・プロトコルの判断は、サーバー側の wp_kses() に任せます。
  • 出力時にもエスケープする:保存時にサニタイズしていても、後から許可リストを変えたり別経路でメタが書き換わったりする可能性があるため、表示時にも同じ wp_kses() を通しています。
  • 権限は投稿単位で確認する:current_user_can( 'edit_posts' ) のような一般的な権限ではなく、edit_post と投稿 ID の組み合わせで確認すると、他人の投稿を書き換えられる事故を防げます。
  • アクセシビリティ:role="textbox" と aria-multiline="true"、ラベルを付けておくと、スクリーンリーダーで編集領域として認識されやすくなります。保存結果は aria-live の領域で伝えています。

動作確認は、ブラウザの開発者ツールのネットワークタブで REST リクエストが 200 を返すこと、ログアウトした状態や投稿者以外のアカウントでは 401 または 403 で拒否されることを、両方とも確かめておくと安心です。<script> やイベント属性付きのタグを貼り付けたり、REST API へ直接送ったりして、保存後のメタ値から取り除かれていることもテストしてください。

まとめ

  • contenteditable は軽い書式編集には十分ですが、ブロックエディタ内なら RichText、管理画面なら wp_editor()、本格的な編集なら保守されているライブラリが適しています。
  • 保存は REST API で行い、permission_callback で投稿単位の edit_post 権限を確認し、nonce を X-WP-Nonce ヘッダーで送ります。
  • 保存する HTML は許可リストを決めて wp_kses() で絞り込み、表示時にも同じ処理を通します。
  • 貼り付けはプレーンテキストに限定し、IME 変換中(isComposing)は保存しないようにすると、日本語環境での不具合を避けられます。
  • 本番に入れる前にステージングで試し、権限の無いユーザーで保存が拒否されることも確認します。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲