エディタを支える『HTMLを編集するUI』の仕組み
目次
CMS を開発する上で必ず必要不可欠な UI として、ブラウザ上で「HTMLを編集するUI」が存在します。
contenteditable に始まり、TinyMCE や CKEditor のようなリッチエディタ、Gutenberg のようなブロックエディタと呼ばれるUIが登場し、Webブラウザの中でHTMLを編集できるようになりました。
しかし、その裏側の仕組みは想像以上に複雑で、
Undoやペースト、IME入力、共同編集といった基本的な操作すら一筋縄ではいきません。
本記事では、「HTMLを編集するUI」であるエディタの仕組みや整理し、
そこから「スキーマを編集するUI」へ至る流れを辿ってみます。
HTMLを編集するUIとは
contenteditable は、DOMを直接編集可能にするブラウザ標準のAPIです。
HTMLの構造をそのままユーザー操作で書き換えられるため、
「見たまま編集(WYSIWYG)」を実現する最も手軽な方法として広まりました。
TinyMCE や CKEditor などはこの仕組みの上に構築され、
見出しやリンク、太字などのHTML要素を直接操作するUIを提供してきました。
ただし、HTMLを直接扱う方式には構造的な限界があります。
ブラウザごとにcontenteditableの挙動が微妙に異なり、
Undoやペースト操作でタグが壊れたり、IME入力中にカーソルが飛ぶこともあります。
このあたりの挙動を解決するために、ペーストや改行時の処理や、Range や Selection といった API を用いた処理を独自に実装する必要がありました。
つまり「HTMLを編集する」というシンプルなモデルは、
実際には壊れやすい構造を必死に維持する戦いでもあったのです。
たとえば、太字になっているテキストと通常のテキストを一緒に選択して
「<span> で囲む」操作をした場合、次のようなことが起きます
<!-- 初期HTML -->
<p><strong>太字</strong>と普通のテキスト</p>
<!-- 期待する結果 -->
<p><strong>太<span>字</span></strong><span>と普通</span>のテキスト</p>
<!-- 実際に起きる結果 -->
<p><strong>太</strong><span>字と普通</span>のテキスト</p>泥臭く愚直にガードを積めば個々のケースは抑えられますが、境界条件の組み合わせが指数的に増えるため、実装・保守コストが現実的ではありません。
具体的には、
- 装飾の交差(bold/italic/link/code 等 N種類 → 2^N 通り)
- ノード境界の跨ぎ(インライン/ブロック/空要素/入れ子)
- ブラウザの正規化差(normalize(), 空白折り返し, 自動タグ統合)
- IME合成中/双方向テキスト/サロゲートペア/ZWSP/絵文字
- ペースト入力のクリーニング(外部HTMLの属性・スタイル・ネスト)
などを同時に考慮する必要があり、ルールの衝突と回帰が頻発します。
スキーマ駆動エディタ
DOMを直接操作することには限界があるため、DOM ではなくモデル(データ)を操作し、HTMLは単なるビューとして扱う仕組みが誕生しました。
CKEditor 5 や ProseMirror は、
「HTMLはビューであり、真実のソースはモデルにある」という思想に基づいています。
文書をスキーマとして定義し、
「段落」「見出し」「リンク」「画像」などの構造をデータモデルとして扱います。
例えば、以下は ProseMirror で「段落」の データ構造を表現する例です。
{
group: "block",
content: "inline*",
parseDOM: [{ tag: "p" }],
toDOM() {
return ["p", 0];
},
}また、CKEditor 5 で <abbr> の プラグインを作成する場合は次のようなコードになります。
// https://github.com/ckeditor/ckeditor5-tutorials-examples/blob/main/abbreviation-plugin/part-1/abbreviation/abbreviationediting.js
import { Plugin } from 'ckeditor5';
export default class AbbreviationEditing extends Plugin {
init() {
this._defineSchema();
this._defineConverters();
}
_defineSchema() {
const schema = this.editor.model.schema;
// Extend the text node's schema to accept the abbreviation attribute.
schema.extend( '$text', {
allowAttributes: [ 'abbreviation' ]
} );
}
_defineConverters() {
const conversion = this.editor.conversion;
// Conversion from a model attribute to a view element
conversion.for( 'downcast' ).attributeToElement( {
model: 'abbreviation',
// Callback function provides access to the model attribute value
// and the DowncastWriter
view: ( modelAttributeValue, conversionApi ) => {
const { writer } = conversionApi;
return writer.createAttributeElement( 'abbr', {
title: modelAttributeValue
} );
}
} );
// Conversion from a view element to a model attribute
conversion.for( 'upcast' ).elementToAttribute( {
view: {
name: 'abbr',
attributes: [ 'title' ]
},
model: {
key: 'abbreviation',
// Callback function provides access to the view element
value: viewElement => {
const title = viewElement.getAttribute( 'title' );
return title;
}
}
} );
}
}このように、 ProseMirror や、内部的に ProseMirror を利用している tiptap 、CKEditor 5 といったリッチエディタは内部モデルを持っています。
HTMLをデータ構造として捉えることで先程の太字のテキストと通常テキストを選択して編集した場合もフラットな構造で扱うことができるようになります。
内部モデルとして扱うことで実際のHTMLとは切り離せるため、結果として 理想的な状態 の HTMLを保持しやすくなります。
さらに、WordPress の Gutenberg やなどで有名なブロックエディタと呼ばれるエディタも内部モデルとしてデータ構造を持っています。
The Block Editor’s Data などのように、ブロックエディタのデータを取得・操作するAPIも整備されており、開発者が自由に拡張できるようになっている点も魅力です。
いずれも「HTMLを直接編集するUI」を超え、
「構造を編集するUI」=スキーマ的UIに進化した例です。
ProseMirror
ProseMirror は、モダンなリッチテキストエディタを構築するためのフレームワークです。
いわゆる「エディタの中身(テキスト編集UI)」を作るための土台であり、
HTMLを直接編集するのではなく、「ドキュメントの構造(モデル)」を直接扱うのが特徴です。
ProseMirrorでは、ユーザーが入力した内容を「HTML」としてではなく、
Schemaで定義されたツリー構造(Document Model)として保持します。
この構造に対して「トランザクション(Transaction)」を流し込み、
挿入・削除・装飾などの変更を行います。
Undo/Redo、共同編集、構造検証などが、すべてこのモデル層で一貫して扱われます。
- 「太字をつける」は toggleMark('strong') というコマンドとしてTransactionを発行
- 「段落を追加する」は Node を生成してドキュメントツリーに挿入
- HTMLは結果的に“ビュー”としてレンダリングされるだけ
つまり、ProseMirrorは「HTMLを編集するライブラリ」ではなく、
「HTMLを生成するモデルを編集するライブラリ」です。
このように、ProseMirror は非常に柔軟で強力ですが、その分、学習コストが高いという問題があります。
Schema、Node、Transaction、Plugin、View、State…
それぞれが抽象的で、役割を誤解すると一気に壊れます。
また、 prosemirror-view、 prosemirror-state、 prosemirror-model…
など、それぞれの機能が別パッケージとして分離され、依存し合っており、依存関係でも問題が発生し苦労します。
Tiptap
Tiptap は、ProseMirror の抽象的な構造を 実用レベルに落とし込んだ ProseMirror のラッパーライブラリです。
Tiptap の登場により、ProseMirrorの低レイヤーAPIを
「開発者が安全に・直感的に扱えるように抽象化」になりました。
Tiptapがもたらした変化
| 課題 | Tiptapによる解決 |
|---|---|
| ProseMirror APIが分散している(state, view, model, command, pluginなど) | → Extension API に統一。1ファイルで Schema + Command + Plugin を宣言可能。 |
| React/Vueとの連携が難しい | → Hooks的API (useEditor) により、state管理とUI更新が自然に同期。 |
| ProseMirror依存パッケージを個別に管理する必要があった | → @tiptap/pm にすべて統合され、依存関係の破綻リスクを排除。 |
特に内部的に使用する ProseMirror パッケージを @tiptap/pm という統一的なバンドルにまとめてくれていることで、
"dependencies": {
"prosemirror-model": "^1.18.0",
"prosemirror-view": "^1.31.1",
"prosemirror-state": "^1.4.2",
...
}
と個別に管理する必要がなくなり、
バージョン整合性や依存解決の責任を Tiptap 側が担ってくれるようになりました。
これにより、ProseMirror の複雑な内部依存(model/state/view間の破壊的変更)を
開発者が気にせずに済むようになりました。
editor
.chain()
.focus()
.setLink({ href: 'https://example.com' })
.run(),このようにすることで選択中のテキストに簡単にリンクを設定することができます。
他にも、React, Vue などのライブラリとの統合もスムーズに行うことができます。
例えば、React の場合は useSyncExternalStore という API を用いています。
// https://github.com/ueberdosis/tiptap/blob/develop/packages/react/src/useEditor.ts
export function useEditor(
options: UseEditorOptions = {},
deps: DependencyList = [],
): Editor | null {
const mostRecentOptions = useRef(options)
mostRecentOptions.current = options
const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions))
const editor = useSyncExternalStore(
instanceManager.subscribe,
instanceManager.getEditor,
instanceManager.getServerSnapshot,
)
// ...省略
}useSyncExternalStore を用いることで Tiiptap で管理しているモデルの状態を React 側に同期しています。
React への状態の同期ができればあとはモデルを編集するための UI をReact で作ったり、Extension 機能を用いて Schema や Command の定義を行えば堅牢かつ使いやすいエディタを開発できます。
まとめ
エディタ開発には、UI操作そのものに注力する傾向がありますが、真価を発揮するのは モデル の設計です。
HTMLをデータ構造として考えることで、
編集とは「DOM を変えること」ではなく「モデルを更新すること」になります。
UI はその結果を見せるビューにすぎません。
エディタ開発の本質は UI ではなくモデル設計にあります。
