セーターを着た女性が紺色のペンを持って紙に図を書いています。写真にはその手元だけが写っており、奥にはコーヒーカップも見えます。

令和に作るリッチテキスト (WYSIWYG) エディタ

// Application

WYSIWYGという言葉を聞いたことがあるでしょうか。“What You See Is What You Get”(見たままが出力される)の略で、エディタなどのツールで編集中の内容が最終的にどのように表示されるかをリアルタイムで確認できる機能を指します。最近はあまり耳にしなくなったこの言葉ですが、その歴史は非常に古いものです。

現在では、NotionSlackDiscordGmailなど、多くのツールでこの機能を実装したエディタ、通称WYSIWYGエディタが使われています。最近では「リッチテキストエディタ」と呼ばれることが多いかもしれません。

これらのツールで当たり前のようにリストやテーブル、画像の挿入、テキストのスタイル変更などができるのは、WYSIWYGエディタが実装されているからです。そして、ブラウザで使用されているリッチテキストエディタの多くはJavaScriptで作られています。しかし、これらのエディタを自作することは、見た目のシンプルさ以上にとても複雑なものになります。

既存のリッチテキストエディタライブラリ

実際に自作に入る前に、現在広く使用されている(使用されていた)リッチテキストエディタライブラリをおさらいしてみましょう。

TinyMCE

TinyMCEは、オープンソースのリッチテキストエディタライブラリです。非常に多機能で、カスタマイズ性が高いことが特徴です。また、多言語対応やプラグインの追加が容易なため、多くのプロジェクトで使用されています。

CKEditor

CKEditorは、TinyMCEと並ぶリッチテキストエディタライブラリです。フル機能で、商用・オープンソースの両方のバージョンがあります。

Quill

Quillは、軽量でモダンなリッチテキストエディタです。シンプルなAPIで使用しやすく、オープンソースで無料で使用できることも魅力です。

Draft.js

Draft.jsは、Facebookが開発したReact用のリッチテキストエディタライブラリです。柔軟なカスタマイズとイミュータブルなデータ構造で堅牢なエディタを実現することができますが、現在はメンテナンスされておりません。

Slate

Slateは、フレキシブルで拡張可能なReact用リッチテキストエディタライブラリです。プラグインシステムを使用して、ニーズに合わせた細かい調整が可能です。

ProseMirror

ProseMirrorは、スキーマに基づいてドキュメント構造を定義する柔軟なデータモデルを採用した、拡張可能なリッチテキストエディタライブラリです。リアルタイムコラボレーションなどにも対応しており、UIの設計も自由に行うことが出来ます。

リッチテキストエディタの実装に伴う課題点

自らのアプリケーションにリッチテキストエディタを実装する一番の近道は、上記のようなライブラリを使用することです。しかし、もし上記のライブラリではアプリケーションの要件を満たせなかった場合、自らリッチテキストエディタを実装しなければならないこともあるでしょう。しかし、それは多くの理由からおすすめできません。では、なぜリッチテキストエディタの自作がおすすめできないのでしょうか?

1. 複雑な仕様

リッチテキストエディタの実装は、見た目以上に複雑です。テキストのフォーマッティング、カーソル位置の管理、IMEの挙動など、多くの要素を考慮する必要があります。これらの仕様をすべて満たすのは非常に困難であり、バグを生みやすいです。

2. ブラウザの互換性

ブラウザにより実装やIMEの挙動が異なるため、これらに対応したエディタを自作することは非常に困難です。すべての環境で一貫した動作を保証するのは、技術的に複雑で時間がかかります。

3. セキュリティとインジェクション攻撃

リッチテキストエディタでは、ユーザーが入力する内容を安全に処理する必要があります。XSS(クロスサイトスクリプティング)などの脆弱性を防ぐためには、非常に注意深い実装が求められます。特に、HTMLを直接扱うため、悪意のあるスクリプトが埋め込まれるリスクがあります。これを防ぐためには、入力内容の検証やサニタイズ(無害化)を徹底する必要があり、これには専門的な知識と経験が必要です。

4. メンテナンス

自作したリッチテキストエディタは、アップデートやバグ修正、機能追加に対するメンテナンスが必要です。将来的な変更やバージョンアップに対応するための継続的な労力が求められます。また、ブラウザの仕様変更に対応するためのアップデートも必要です。

5. 機能の実装の難しさ

リッチテキストエディタには、Undo/Redo機能、画像の挿入、テーブルの作成、リッチフォーマットなど、様々な高度な機能が求められます。これらを自作するのは非常に手間がかかり、実装が難しい場合が多いです。

以上の理由から、リッチテキストエディタを実装することはあまりおすすめできません。多くのアプリケーションにおいてリッチテキストエディタ自体がコアな機能ではないでしょう。大きな時間とコストがかかるこれらの実装を行うのであれば、既存のライブラリを使用することを強くおすすめします。
それでも、今回はリッチテキストエディタの自作に挑戦してみます。その際に、上記の課題点を意識しながら、どのように実装していくべきかを考えていきます。

実装

それでは実践してみましょう。まずはシンプルなリッチテキストエディタを作成しつつ、徐々に機能を追加していきます。
なお、今回作成したプロジェクトは公開しておりますので、興味のある方はクローンして手元で動かしてみてください。

https://github.com/mast1ff/2024-10-08_vanilla-rte

一番シンプルな実装

TypeScriptを使用したいのですが、一からビルド設定を行うのは今回の主題ではありませんので、ビルドツールにViteを使用します。 サクッとViteのプロジェクトを立ち上げます。今回はUIフレームワークやライブラリは使用しないため、Vanillaを選択します。

$ pnpm create vite@latest

✔ Select a framework: › Vanilla
✔ Select a variant: › TypeScript

Scaffolding project in /Users/mast1ff/repos/@playgrounds/2024-10-08_rte...
Done. Now run:
  pnpm install
  pnpm run dev

それぞれのファイルを編集していきます。

index.html
<!doctype html>
<html lang="ja-JP">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Rich Text Editor</title>
  </head>
  <body>
    <!-- エディタのルート -->
    <div data-editor-root class="editor">
      <!-- エディタのツールバー -->
      <div class="editor__toolbar" data-editor-target="toolbar">
        <button data-editor-command="bold">Bold</button>
        <button data-editor-command="italic">Italic</button>
        <button data-editor-command="underline">Underline</button>
      </div>
      <!-- エディタの本文などのコンテンツ -->
      <div class="editor__content" data-editor-target="editor"></div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
src/rte.ts
function execCommand(command: string) {
  document.execCommand(command, false);
}

export function setupRichTextEditor(root: HTMLElement) {
  const toolbar = root.querySelector<HTMLElement>(
    `[data-editor-target="toolbar"]`
  );
  const editor = root.querySelector<HTMLElement>(
    `[data-editor-target="editor"]`
  );
  if (!editor) {
    throw new Error(`[data-editor-target="editor"] not found`);
  }

  if (toolbar) {
    const buttons = toolbar.querySelectorAll<HTMLButtonElement>(
      `[data-editor-command]`
    );
    for (const button of buttons) {
      button.addEventListener("click", () => {
        const command = button.getAttribute("data-editor-command")!;
        execCommand(command);
      });
    }
  }

  editor.contentEditable = "true";
}
src/main.ts
import "./style.css";
import { setupRichTextEditor } from "./rte.ts";

setupRichTextEditor(document.querySelector(`[data-editor-root]`)!);
src/style.css
*,
*::before,
*::after {
  box-sizing: border-box;
}

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

a {
  font-weight: 500;
  color: #646cff;
  text-decoration: inherit;

  &:hover {
    color: #535bf2;
  }
}

body {
  margin: 0;
  min-height: 100vh;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;

  &:hover {
    border-color: #646cff;
  }

  &:focus,
  &:focus-visible {
    outline: 4px auto -webkit-focus-ring-color;
  }
}

@media (prefers-color-scheme: light) {
  :root {
    color: #213547;
    background-color: #ffffff;
  }
  a:hover {
    color: #747bff;
  }
  button {
    background-color: #f9f9f9;
  }
}

.editor {
  padding: 32px;
  display: flex;
  flex-direction: column;
  min-height: 100vh;

  .editor__toolbar {
    border-bottom: 1px solid hsla(0 0% 0% / 0.12);
    padding-block-end: 1em;
  }

  .editor__content {
    padding-block-start: 1.5em;
    flex: 1;

    &:focus,
    &:focus-visible {
      outline: none;
    }
  }
}

リッチテキストエディタを実装するためには、<textarea></textarea>などとは違い、入力値とHTMLを同期させる必要があります。そのため、<div></div>要素にcontenteditable属性を設定し、ユーザーが直接編集できるようにします。今回の実装では、後からcontenteditable属性を付与していますが、以下のように先にHTMLに記述しても構いません。
この属性を設定した要素は、ユーザーが直接編集出来るようになります。

contenteditable属性

<div contenteditable="true"></div>

これで直接要素が編集出来るようになったとはいえ、エディタ内の要素やスタイルを変更することは出来ません。要素のスタイルやタグを変更するなどのcontenteditable属性を持つ要素に対しての操作を行うには、JavaScriptのdocument.execCommand()メソッドを使用します。

document.execCommand()

このメソッドは、指定されたコマンドを実行します。例えば、boldコマンドを実行すると、選択されたテキストを太字にします。このように、document.execCommand()メソッドを使用することで、リッチテキストエディタの機能を実装することが出来ます。

document.execCommand("bold"); // 選択されたテキストを太字にする
document.execCommand("italic"); // 選択されたテキストを斜体にする
document.execCommand("underline"); // 選択されたテキストに下線を引く
document.execCommand("insertOrderedList"); // 選択されたテキストを順序付きリストにする
document.execCommand("insertUnorderedList"); // 選択されたテキストを順序なしリストにする

ここでは、ツールバー内の各button要素のdata-editor-command属性に対応するコマンドを実行するようにしています。これにより、ユーザーがボタンをクリックすることで、テキストのスタイルを変更することが出来ます。

<button data-editor-command="bold">Bold</button>
<button data-editor-command="italic">Italic</button>
<button data-editor-command="underline">Underline</button>

ブロックの実装

現在のエディタは、テキストのスタイルを変更することが出来ますが、リストや見出しなどのブロック要素を挿入することは出来ません。これらのブロック要素の挿入についても実装していきます。

index.html
<!-- エディタのツールバー -->
<div class="editor__toolbar" data-editor-target="toolbar">
  <span>インラインスタイル: </span>
  <button data-editor-command="bold">Bold</button>
  <button data-editor-command="italic">Italic</button>
  <button data-editor-command="underline">Underline</button>
  <br />
  <!-- 文章のアラインメント -->
  <span>アラインメント: </span>
  <button data-editor-command="justifyLeft">Left</button>
  <button data-editor-command="justifyCenter">Center</button>
  <button data-editor-command="justifyRight">Right</button>
  <br />
  <!-- ブロック要素 -->
  <span>ブロック要素: </span>
  <button data-editor-command="heading1">Heading1</button>
  <button data-editor-command="heading2">Heading2</button>
  <button data-editor-command="heading3">Heading3</button>
  <button data-editor-command="paragraph">Paragraph</button>
  <br />
  <!-- リスト -->
  <span>リスト: </span>
  <button data-editor-command="insertOrderedList">Ordered List</button>
  <button data-editor-command="insertUnorderedList">Unordered List</button>
</div>
src/rte.ts
// コマンドを型安全に扱うための型定義
type Command =
  | "bold"
  | "italic"
  | "underline"
  | "justifyLeft"
  | "justifyCenter"
  | "justifyRight"
  | "heading1"
  | "heading2"
  | "heading3"
  | "paragraph"
  | "orderedList"
  | "unorderedList";

function execCommand(command: Command) {
  // コマンドによって処理を分岐
  switch (command) {
    case "heading1":
      document.execCommand("formatBlock", false, "h1");
      break;
    case "heading2":
      document.execCommand("formatBlock", false, "h2");
      break;
    case "heading3":
      document.execCommand("formatBlock", false, "h3");
      break;
    case "paragraph":
      document.execCommand("formatBlock", false, "p");
      break;
    default:
      document.execCommand(command, false);
  }
}

// 省略

この実装により、指定した文章を右詰めにしたり、見出しを挿入したりすることが出来るようになりました。

保存機能の実装

最後に、エディタで入力した内容を保存する機能を実装します。ここでは、sessionStorageを使用して、入力内容をブラウザに保存します。

index.html
<!doctype html>
<html lang="ja-JP">
  <!-- 省略 --->
  <body>
    <!-- エディタのルート -->
    <div data-editor-root class="editor">
      <!-- 省略 --->
      <!-- エディタのフッター -->
      <div class="editor__footer">
        <button data-editor-command="save">Save</button>
      </div>
    </div>
  </body>
</html>
src/rte.ts
type Command = 
  // ...省略
  | "unorderedList"
  | "save";

function execCommand(command: Command) {
  switch (command) {
    // ...省略
    case "save":
      const editor = document.querySelector<HTMLElement>(
        `[data-editor-target="editor"]`
      );
      if (!editor) {
        throw new Error(`[data-editor-target="editor"] not found`);
      }
      const content = editor.innerHTML;
      window.sessionStorage.setItem("__RTE_CONTENT__", content);
      break;
    default:
      document.execCommand(command, false);
  }
}

function setupRichTextEditor(root: HTMLElement) {
  // ...省略

  const content = window.sessionStorage.getItem("__RTE_CONTENT__");
  if (content) {
    editor.innerHTML = content;
  }

  const buttons = root.querySelectorAll<HTMLButtonElement>(
    `[data-editor-command]`
  );
  for (const button of buttons) {
    button.addEventListener("click", () => {
      const command = button.getAttribute("data-editor-command")!;
      execCommand(command as Command);
    });
  }

  editor.contentEditable = "true";
}

これでエディタで入力した内容を保存することが出来るようになりました。ブラウザをリロードしても、保存した内容が復元されることを確認してみましょう。

課題への対応

さて、今回作成したエディタと、先ほどのリッチテキストエディタの実装における課題点に照らし合わせ、なぜこれらの課題が発生するのか、どのような対応が必要なのかを具体的に考えてみましょう。

1. 複雑な仕様

現在作成したリッチテキストエディタでは、画像やテーブルの挿入などの高度な機能は実装されていません。これらの機能を実装するためには、document.execCommand()メソッドだけでは不十分であり、今回のsaveのようなカスタムコマンドの実装が必要です。また、IMEの挙動については、一部の環境でバグを生む可能性があります。現状の機能のみでは問題ありませんが、より多くの機能を追加する際には、複数端末でのテストを徹底することが重要です。

2. ブラウザの互換性

こちらも、現在の機能のみでは大きな問題はありません。しかし、テーブル要素の挿入やそのテーブル内の編集、blockquoteタグの挿入など、ブラウザによって挙動が異なる機能も複数確認されています。また、execCommand()メソッド自体が非推奨となっており将来的には使用できなくなる可能性があるため、入力されたキーに応じてHTMLの挿入処理を行うなどのより複雑な実装が求められます。

3. セキュリティとインジェクション攻撃

現在の保存機能は、挿入されたHTMLをそのままsessionStorageに保存しています。これにより、悪意のあるスクリプトが埋め込まれるリスクがあります。この問題は、バックエンドがsessionStorageではなくサーバーサイドであっても変わりません。これを解決するためには、入力されたHTMLをサニタイズ(無害化)する処理を行うか、そもそもHTMLを保存せず、挿入された内容をオブジェクトとして保存するなどの対策が必要です。
もしコンテンツをオブジェクトとして保持する場合、入力された内容に応じてオブジェクトを生成して構造を保つためのエディタエンジンの実装が不可欠です。

4. メンテナンス

現在のリッチテキストエディタは機能が限定されているため、メンテナンスは比較的容易です。しかし、これ以上の機能を追加する際には、コードのリファクタリングやテストの追加、ドキュメントの整備などが必要です。また、execCommand()メソッドが非推奨となる可能性があるため、その際には新しい実装に移行する必要があります。

まとめ

リッチテキストエディタへの理解と基本的な実装を行ってまいりました。リッチテキストエディタの自作は非常に複雑で多くの課題がありますが、その分学びも多いです。しかし、それはブラウザの仕様やセキュリティ、メンテナンスなど、多くの課題と戦うことでもあります。そのため、本番環境用のリッチテキストエディタを実装する際には、既存のライブラリを使用することをおすすめします。

興味のある方は、今回の実装を元に独自の機能を追加したり、改良してみてください。

P.S.

みんなMarkdowおぼえようよ

参考文献