WYSIWYGエディタの設計と実装

WYSIWYGとは何か

WYSIWYG(What You See Is What You Get)は「見たままが得られる」という意味で、編集中の画面が最終出力と同じ見た目になるエディタの設計思想である。

これはモードレスデザインの一形態とも言える。従来のエディタが「編集モード」と「プレビューモード」を分離していたのに対し、WYSIWYGはその境界をなくし、ユーザーの認知負荷を下げるアプローチだ。

従来型:  [編集モード] ←切替→ [プレビューモード]
              ↓                    ↓
         マークアップを見る    結果を見る

WYSIWYG: [編集 = プレビュー]
              ↓
         常に結果を見ながら編集

歴史的背景

WYSIWYGの概念は1970年代のXerox PARCで生まれ、1984年のMacintoshで一般に広まった。Webの文脈では、2000年代にcontenteditable属性とexecCommand APIを使った実装が主流だった。

しかし、この「ブラウザ任せ」のアプローチには問題があった:

2010年代、Reactに代表される「仮想DOM」の思想が広まると、WYSIWYGエディタも「状態を持ち、そこからDOMを導出する」方向に進化した。これが現代のProseMirror、Slate、Lexicalといったライブラリの設計思想である。

データモデル:WYSIWYGエディタの核心

エディタの設計において、最も重要な決定は「ドキュメントをどう表現するか」である。

アプローチ1: HTML直接

// 最もシンプル(だが問題だらけ)
document.execCommand('bold');
const html = editor.innerHTML;

ブラウザに任せるため実装は簡単だが、前述の問題を抱える。

アプローチ2: 独自AST(現代の主流)

{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "これは" },
        { "type": "text", "text": "太字", "marks": [{ "type": "bold" }] },
        { "type": "text", "text": "のテキスト" }
      ]
    },
    {
      "type": "heading",
      "attrs": { "level": 2 },
      "content": [
        { "type": "text", "text": "見出し" }
      ]
    }
  ]
}

ポイントは3つ:

  1. ノードの階層構造 - doc > paragraph > text のようなツリー
  2. マーク(装飾)の分離 - bold, italic などはテキストノードに付与される属性
  3. 属性(attrs) - heading の level のようなノード固有の設定

アプローチ3: 操作ログ(協調編集向け)

// 状態ではなく「操作」を記録
{ type: "insert", pos: 5, text: "こんにちは" }
{ type: "delete", pos: 10, length: 3 }
{ type: "addMark", from: 5, to: 10, mark: "bold" }

リアルタイム同期やUndo/Redoに適しているが、競合解決(CRDTやOT)が必要になる。

データモデルと実装の依存関係

┌─────────────────┐
│  データモデル    │
├─────────────────┤
│ ・HTML直接      │──→ contenteditable + execCommand(レガシー)
│ ・独自AST       │──→ 仮想DOM的レンダリング(ProseMirror, Slate)
│ ・操作ログ      │──→ CRDT/OT(リアルタイム協調編集)
└─────────────────┘
         ↓
    実装の複雑さ・機能性が決まる

選んだデータモデルが、実装の選択肢を大きく制約する。これは「どのライブラリを使うか」以前の、アーキテクチャ上の重要な決定だ。

WYSIWYGエディタの技術要素

エディタを構成する技術要素を分解すると、以下のようになる:

要素 説明
スキーマ定義 どんな構造を許可するか
状態管理 ドキュメントの現在状態
DOMレンダリング 状態→画面表示
Selection管理 カーソル位置、選択範囲
入力処理 キーボード、IME、ペースト
Undo/Redo 履歴管理
シリアライズ HTML/Markdown/JSON変換
コラボレーション リアルタイム同期
UI ツールバー、メニュー

ライブラリ比較

主要ライブラリの責務範囲

要素 ProseMirror TipTap Slate Lexical Quill
スキーマ定義 ◎ 厳格 ◎ (PM継承) ○ 柔軟 △ 固定的
状態管理
DOMレンダリング ◎ 独自 ◎ React依存 ◎ React優先 ◎ 独自
入力/IME △ 課題あり
Undo/Redo ◎ plugin ◎ 組込 ◎ plugin ◎ 組込 ◎ 組込
コラボ ○ Yjs連携 ◎ Yjs拡張 △ Yjs連携
UI × なし ○ ヘッドレス × なし × なし ◎ 組込

選定の判断軸

カスタマイズ性重視     → ProseMirror(直接)or Slate
開発速度重視          → TipTap or Quill
React統合重視         → Slate or Lexical
日本語対応重視        → ProseMirror系(TipTap含む)
コラボレーション重視   → TipTap + Yjs
フレームワーク非依存   → ProseMirror

ProseMirrorの設計思想

ProseMirrorは「レゴブロック」型のアーキテクチャを採用している:

prosemirror-model    → スキーマ、ドキュメント構造
prosemirror-state    → EditorState、Transaction
prosemirror-view     → EditorView、DOM同期
prosemirror-transform→ ドキュメント変換操作
prosemirror-commands → 基本コマンド(太字、見出し等)
prosemirror-history  → Undo/Redo
prosemirror-keymap   → キーバインド

各パッケージが独立しており、必要なものだけ組み合わせる。自由度は最高だが、学習コストも最高である。

スキーマの「厳格さ」とは

ProseMirrorの「スキーマが厳格」というのは、制約ではなく設計思想だ。

// 独自ノードを定義する例
const schema = new Schema({
  nodes: {
    doc: { content: 'block+' },
    paragraph: {
      content: 'inline*',
      toDOM: () => ['p', 0]
    },
    // 独自ノード:コードブロック with 言語指定
    code_block: {
      content: 'text*',
      attrs: { language: { default: 'javascript' } },
      toDOM: (node) => ['pre', { 'data-language': node.attrs.language }, ['code', 0]]
    },
    text: { group: 'inline' }
  },
  marks: {
    bold: { toDOM: () => ['strong', 0] },
    // 独自マーク:ハイライト色指定
    highlight: {
      attrs: { color: { default: 'yellow' } },
      toDOM: (mark) => ['mark', { style: `background: ${mark.attrs.color}` }, 0]
    }
  }
})

「スキーマに定義されていないものは入らない」という厳格さは、バグ防止になる。不正なHTMLがペーストされても正規化される。独自構造はスキーマに定義すれば何でも作れる。

TipTapとの関係

TipTapはProseMirrorの「親切なラッパー」である。

// TipTapはProseMirrorの複雑さを隠蔽
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'

const editor = new Editor({
  extensions: [StarterKit], // 基本機能がバンドル
  content: '<p>Hello</p>',
})

TipTapのコア部分(@tiptap/core)はフレームワーク非依存だが、@tiptap/react@tiptap/vue-3といったバインディングと組み合わせたときに真価を発揮する。

ProseMirrorの全機能にアクセス可能で、よく使うパターンは簡単に書ける。実用上はTipTapを選ぶケースが多いが、フレームワーク非依存やネイティブアプリ対応を重視するなら、ProseMirror直接の学習価値がある。

実装上の注意点

IME(日本語入力)対応

日本語ユーザーをターゲットにする場合、IME対応は必須である。SlateはIME周りで問題が出やすい(日本語入力の途中で確定されるなど)ことが知られている。ProseMirror系(TipTap含む)は比較的安定している。

協調編集

リアルタイム協調編集を実装する場合、YjsのようなCRDTライブラリとの連携が必要になる。これはエディタライブラリとは独立した同期基盤として機能する。

┌─────────────┐     ┌─────────────┐
│  TipTap     │     │  Yjs        │
│  (編集UI)    │ ←→ │  (同期基盤)  │
└─────────────┘     └─────────────┘
                          ↓
              ┌───────────────────┐
              │  y-websocket      │
              │  (通信層)          │
              └───────────────────┘

Yjsは編集履歴(Undo/Redo)もサポートしており、協調編集時でも「自分の操作だけ戻す」が可能。

まとめ

WYSIWYGエディタの設計において重要なのは:

  1. データモデルの選択が実装全体を規定する
  2. スキーマの厳格さはバグ防止と予測可能性のため
  3. ライブラリ選定は要件(フレームワーク依存、IME、協調編集)に基づく
  4. ProseMirrorは自由度と学習コストのトレードオフ

「見たままが得られる」というシンプルな理想を実現するために、現代のWYSIWYGエディタは複雑な内部構造を持つ。しかし、その複雑さは「予測可能で拡張可能なエディタ」を作るための必然的な帰結なのかもしれない。

関連

抽出された概念