1
/
5

WYSIWYGエディターの状態抽象化とReactとのつなぎ込み - 良いクロスブラウザエディターを目指して

Photo by James Pond on Unsplash

まえがき

ブログや記事を投稿するWebサービスには、殆どの場合、リッチなエディター機能が備わっています。特に、記事を見た目通りに書けるエディターは、WYSIWYGエディターと呼ばれ、記事を書く際には欠かせない機能の一つです。

Wantedly にもストーリーと呼ぶ記事を投稿する機能があり、その記事投稿部分では、WYSIWYGエディターを実装しています。 では、どのようにWYSISYGエディターを開発していくのでしょうか。この記事では、エディターの機能を実際に開発するための基礎知識と実装について書いていきます。

今回話すエディターについて

今回記事で指すエディターとは、 WYSIWYGエディターを想定しています。例えば、Dropbox Paper や google docs、Medium、Note などのエディターがそれに該当します。Qiita や Zenn のような 2カラムの Markdown Editor についてはこの記事では触れません。

さて、多くのエディターは以下で構成されています。

  • テキストエリア
  • 文字の装飾
  • コンテンツの挿入
  • その他の記事の設定

記事を書くという意味では、テキストエリアだけで十分ですが、それを補うために、文字の装飾やコンテンツの挿入にはそのためのツールバー(ここではインサーターと呼ぶ)が実装されることが多いです。

以下は Wantedly で使用しているインサーターです。

良いエディターとは?

では、エディターを開発していく上で、目指すべきものはなんでしょうか。良いエディターを開発する上で目指す体験はそれぞれ異なるとは思いますが、直感的に記事を書けることは前提条件としてありそうです。例えば以下の機能は上記であげたエディターには全て実装されています。

  • ショートカットキーを用いた文字の装飾
  • インサーターによる文字の装飾
  • Drag & Drop による画像の挿入
  • Ctrl+S で編集内容を保存

もちろん他にも必要となる機能はありますが、今回は上記の機能についての実装を記述していきます。

ライブラリを使わないエディターの開発

まず、プレーンなHTMLとJSでエディターを開発していくことを考えてみましょう。その場合には、contenteditable と呼ぶ、 HTML の仕様で策定されている DOM Element を編集可能にする attribute を利用します。

https://html.spec.whatwg.org/multipage/interaction.html#attr-contenteditable

<cite contenteditable="true">Write your own name here</cite>

上記のように contenteditable 属性を true に設定することで、DOM Element に フォーカスした時、input や textarea のように、caret (テキスト挿入時の縦棒のこと) が表示され、内部のテキストやDOMを編集することが可能になります。

contenteditable の中にDOMを重ねることも可能です。

<div contenteditable="true">
  <h2>タイトル</h2>
  <p>文字</p>
  <backquote>引用</backquote>
</div>

画像の挿入やテキストの装飾など、よりリッチな表現を行いたい場合はどう実装するのでしょうか?

SelectionexecCommand と言う2つのWeb API を使用して、実装するのが方法の一つとしてあります。Selection API はユーザーが選択している範囲を返すAPIで、execCommand は、現在編集可能な領域を操作するためのコマンドを実行するAPIです。bolditalic といったテキスト装飾のコマンドから、copy , cut という操作系、undo , redo で履歴の編集も行うことができます。

直接 contenteditable を使用する問題点

しかし、 contenteditable を実際に使用してエディターを開発するのは難しいです。例えば、ブラウザによって contenteditable の挙動が違うという問題があります。改段落するという挙動一つとっても、ブラウザ毎に生成されるマークアップが違うと言う問題が(最近まで)ありました。

さまざまなブラウザーで contenteditable を使用することは、ブラウザーが生成するマークアップの違いのために、長い間苦痛でした。例えば、編集可能な要素内で新しいテキストの行を作成するために Enter/Return を押下したときのようなシンプルな場合でさえ、主要なブラウザー間で扱いが異なっていました (Firefox は <br> 要素を挿入、IE/Opera は <p> を使用、Chrome/Safari は <div> を使用)。

ref: https://developer.mozilla.org/ja/docs/Web/Guide/HTML/Editable_content

また、生成されるマークアップが違う事で起きる、想定外のフォーマットに対応する必要があります。システムの仕様から外れたフォーマットが生まれることは避けなければならないのですが、そのためには、エディターを表すためのモデルを適切な抽象化する必要があります。

contenteditableは簡単にWYSIWIGを実現してくれる便利なものではありますが、「テキストが入力できる」を超える部分、システム仕様へのマッチング、ブラウザ間で動作共通化、これらは地道に作り込んでいく他ないんですね。

ref: https://note.com/ct8ker/n/n037f6ba3c318

さらに、HTMLを直接編集する必要があるので、機能の追加や修正をするたびに、複雑性が高まっていく、と言う問題もあります。

HTMLを直接いじって実装していくので、新しい要素を追加していくたびに複雑性が高まっていき、保守や機能追加が困難になっていきます。

ref: https://www.wantedly.com/companies/wantedly/post_articles/28285

contenteditable を使用するためには、これらの問題について対処する必要があり、(おそらく)自前で実装するのは茨の道になるでしょう。

ライブラリを使用したエディターの実装

上記の問題に対応したエディターのライブラリがあります。Dropbox Paperでは、Ace Editor を内部で使用しています。Note では、 Medium like なエディターツールである、Medium Editor を使用しています。他にも有名どころでは、Quill や、Slate などがあります。

これらのライブラリは、エディターの持つ状態の抽象化と、クロスブラウザ対応を基本的に備えています。複雑なエディターを実装する場合には、これらのライブラリを使用することを検討するのが良いでしょう。

Wantedlyでは facebook 製の Draft.js を使用しています。過去の記事で、 Draft.js を使用した経緯などが書いてあるので、ご参考までに。


Facebook謹製フレームワークDraft.js + React.jsでつくるリッチテキストエディタ | Wantedly Engineer Blog
Wantedlyでエンジニアをしている竹本です。主にこのブログを含むフィードを中心に開発をしています。 最近、フィードの記事編集画面のリニューアルを行ったので事例の簡単な紹介と、得られた知見を共有したいと思います。 先日の Meguro.es #4 でDreaft.jsについて発表したスライドはこちらになります。あわせて参照して下さい。 Wantedlyのフィードの記事編集画面は以下の画像のような、いわゆるリッチテキストエディタです。 これは記事を書くユーザーのイメージと乖離した記事が公開されないように、
https://www.wantedly.com/companies/wantedly/post_articles/28285

次の節から、実際に Draft.js を使ったエディターの実装について説明します。

Draft.js について

Draft.js は facebook が開発している React のエディターライブラリです。クロスブラウザ対応しており、EditorState と呼ばれるステートで、エディターの状態を抽象化しています。内部では immutable.js を使用しており、immutable の特性を生かして、履歴機能などを実現しているのが特徴です。

以下が今回ベースとするコードになります。

import React from 'react';
import ReactDOM from 'react-dom';
import {Editor, EditorState} from 'draft-js';
import 'draft-js/dist/Draft.css';

function MyEditor() {
  const [editorState, setEditorState] = React.useState(
    () => EditorState.createEmpty(),
  );

  return <Editor editorState={editorState} onChange={setEditorState} />;
}

ReactDOM.render(<MyEditor />, document.getElementById('container'));

Draft.js におけるエディターの状態抽象化

基本的には、 EditorState と呼ばれるエディターの状態の変更を記述することで、エディターを実装していきます。日本語で書かれた EditorState の概要については、以下の記事が参考になります。

苦しんで覚えるDraft.js -リッチテキストエディタをシュッと作る- - Qiita
株式会社LITALICOでWebエンジニア(Rails)を担当しています、@YudaiTsukamotoです。 この記事は『LITALICO Advent Calendar 2017』1日目の記事です。 記念すべき1記事目は、Facebook謹製のリッチテキストエディタフレームワークのDraft.jsについて書こうと思います。 弊社では、 conobie ...
https://qiita.com/YudaiTsukamoto/items/264f333e90a1edb818a3#editorstate


EditorState について、簡略的には以下のように表すことができます(実際はより複雑)また、それぞれの状態にある getXXX とある getter を用いて、状態を取得することができます。

エディターを大きく記事の内容と選択範囲に分け、これらをスタックしたものを EditorState として top-level の状態として置いています。スタックとして表現することで履歴の機能を実現します。また、それとは別に、現在のインラインスタイルを表す状態を独立に持っているのがWYSISYGエディターの抽象化において注目すべきポイントです。

Draft.js によるエディターの機能開発

直感的な編集を実現するための機能の実装例を紹介していきます。「良いエディターとは?」の節であげた、以下の4つの基本的な機能について説明します。

  • ショートカットキーを用いた文字の装飾
  • インサーターによる文字の装飾
  • Drag & Drop による画像の挿入
  • Ctrl+S で編集内容を保存

ショートカットキーを用いた文字装飾

例えば Ctrl+B太字Ctrl+IItralic への変換を行いたいです。

文字装飾のスタイリングは、自分が複雑な実装をする必要はなく、 Draft.js が提供している RichUtils.handleKeyCommand によって、簡単に実現することができます。

import { RichUtils, DraftEditorCommand, EditorState, DraftHandleValue} from "draft-js";
 
const handleKeyCommand = useCallback((editorCommand: DraftEditorCommand, editorState: EditorState): DraftHandleValue => {
  const newState = RichUtils.handleKeyCommand(editorState, editorCommand);
  if(newState) {
    setEditorState(newState);
    return "handled";
  }
  return "not-handled";
}, []);

return <Editor editorState={editorState} handleKeyCommand={handleKeyCommand} onChange={setEditorState} />;

送られてきた command に応じて、RichUtils.handleKeyCommand が適用できるコマンドの場合は、新しい EditorState を返します。EditorhandleKeyCommand は、変更した場合は、handled を、そうでない場合は、not-handle と言う識別子を返す必要があります。

このように、一つ具体的な実装例を上げましたが、基本的には、 Editor コンポーネントで定義されている props に則って、EditorState を更新していくこと が、実装の流れになります。

ちなみに、RichUtils.handleKeyCommanddraft-js の実装は以下で、コードにあるコマンドに対応できます。指定されたコマンドに対して操作を行っていることが読み取れます。

handleKeyCommand(
    editorState: EditorState,
    command: DraftEditorCommand | string,
    eventTimeStamp: ?number,
  ): ?EditorState {
    switch (command) {
      case 'bold':
        return RichTextEditorUtil.toggleInlineStyle(editorState, 'BOLD');
      case 'italic':
        return RichTextEditorUtil.toggleInlineStyle(editorState, 'ITALIC');
      case 'underline':
        return RichTextEditorUtil.toggleInlineStyle(editorState, 'UNDERLINE');
      case 'strikethrough':
        return RichTextEditorUtil.toggleInlineStyle(
          editorState,
          'STRIKETHROUGH',
        );
      case 'code':
        return RichTextEditorUtil.toggleCode(editorState);
      case 'backspace':
      case 'backspace-word':
      case 'backspace-to-start-of-line':
        return RichTextEditorUtil.onBackspace(editorState);
      case 'delete':
      case 'delete-word':
      case 'delete-to-end-of-block':
        return RichTextEditorUtil.onDelete(editorState);
      default:
        // they may have custom editor commands; ignore those
        return null;
    }
  }

例えば、以下の行では、インラインスタイルを変更しています。

RichTextEditorUtil.toggleInlineStyle(editorState, 'BOLD');

toggleInlineStyle は、現在文字を選択中の場合は選択している範囲にインラインスタイルを適用し、何も選択されていない場合には、currentInlineStyle にインラインスタイルを適用するメソッドになります。

他にも自分でコマンドをカスタマイズしたい場合には、以下の Document が参考になるでしょう。
https://draftjs.org/docs/advanced-topics-key-bindings/

次に、この handleKeyCommand を使用して、インサーターによる文字の装飾を行います。

インサーターによる文字装飾

文字選択時に表示されるインサーターを実装していきましょう。インサーターは以下のような動作を想定しています。

  • テキストが選択されている場合に文字装飾のためのツールチップを表示する
  • テキストが選択されていない場合には表示しない



まずは、コンポーネントを実装します。今回は簡単のため Bold に絞って説明します。

interface Props {
  style: React.CSSProperties;
  onClickBoldButton: () => void;
}

const InlineStyleInserter: React.FC<Props> = (props) => {
  return (
    <InserterBase style={props.style}>
      <BoldButton onClick={props.onClickBoldButton} />
    </InserterBase>
  );
}

また表示非表示や位置のスタイルを外部から渡してあげることを想定し、style を props に渡すようにしています。また、この Inserterposition: fixed の想定です。

まず、文字選択時かどうかを判定する shouldShowInlineStyleInserter を実装します。文字選択の判定ロジックは以下のように考えると良いでしょう。

  • 選択範囲の開始位置と終端位置が同じでない
  • 選択中の場所に Block 要素を含んでいない
const shouldShowInlineStyleInserter = (editorState: EditorState) => {
  // 現在の選択範囲を取得する
  const selection = editorState.getSelection();
  // 選択範囲である SelectionState の anchor と focus の位置が同じでない
  if (selection.isCollapsed()) {
    return false;
  }
  // 現在の記事の内容を取得する
  const content = editorState.getCurrentContent();
  // 選択中の場所に Block 要素を含んでいない
  // 正確には始点と終点の内部もチェックする必要があるが、始点と終点 atomic ではないという簡易的な判定で代用している
  return (
    content.getBlockForKey(selection.getAnchorKey()).getType() !== "atomic" &&
    content.getBlockForKey(selection.getFocusKey()).getType() !== "atomic"
  );
}

editorState.getSelection で、現在の選択範囲 ( SelectionState )を取得し、editorState.getCurrentContent で、現在の記事の内容全体を取得しています。これらの getter は開発時によく使います。

また、実際にロジックをコードにしている部分は、selection.isCollapsed と、content.getBlockForKey の部分です。

次に、 Inserter を表示するために位置を取得しましょう。draft-js には、getVisibleSelectionRect と言う、Selection から rect を取得する API があるので、これを利用します。

import { getVisibleSelectionRect } from "draft-js";
 
const getInlineStylePosition = (editorState: EditorState) => {
  const rect = getVisibleSelectionRect(window);
  if(!rect) return null;
  // 必要に応じて調整する
  return { top: rect.top, left: rect.left }  
}

上記のメソッドを組み合わせることで、インサーターを実装できます。

const [style, setStyle] = useState({ display: "none" });

const onChange = useCallback((editorState: EditorState) => {
  if(shouldShowInlineStyleInserter(editorState)) {
    const rect = getInlineStylePosition(editorState);
    if(rect) {
      setStyle({ display: "block", ...rect });
    }
  } else {
    setStyle({ display: "none" });
  }
  setEditorState(editorState);
}, []);

return (
  <>
    <InlineStyleInserter style={style} onClickBoldButton={() => handleKeyCommand("bold", editorState)} />
    <Editor editorState={editorState} handleKeyCommand={handleKeyCommand} onChange={onChange} />
  </>
)

今回は簡単のために bold だけを扱いましたが、他のインラインの編集も同様に記述できます。

Drag & Drop による画像挿入

記事を書く上で必要になる画像の挿入と、Drag & Drop による挿入について説明します。

画像を描画するための準備として、 blockRendererFn 関数を定義し、そこで block要素の描画のカスタマイズを行います。ここでは Entity と呼ばれる Block要素をカスタマイズするデータ定義を利用して、entitytypeimage である時に、画像を表示すると言う実装を行います。

ref: https://draftjs.org/docs/advanced-topics-custom-block-render-map

const Image: React.FC<{ blockProps: { src: string }}> = (props) => {
  return <img src={props.blockProps.src} />;
}

const blockRendererFn = useCallback((block: ContentBlock) => {
  if(block.getType() === "atomic") {
    const entityKey = block.getEntityAt(0);
    if(!entityKey) return null;
    const entity = editorState.getCurrentContent().getEntity(entityKey);
    if (!entity) return null;
    if(entity.getType() === "image") {
      const data = entity.getData();
      return {
        component: Image,
        editable: false,
        props: {
          src: data.src,
        }
      }
    }
  }
  return null;
}, [editorState]);

return <Editor blockRendererFn={blockRendererFn} />

コンポーネントと、それが editable であるかどうか、そして、コンポーネントに渡す props を用意します。(entity.getDate は型が any なので、TypeSafe ではないのがちょっと残念です...)

実際に、画像を挿入するためのハンドラーを実装します。幸い、Editor には、Drop時の callback である、handleDroppedFiles があるので、こちらを利用できます。

uploadImage の実装は blob からファイルをアップロードして url を返す関数とします。

const handleDroppedFiles = useCallback((selection: SelectionState, blobs: Blob[]): DraftHandleValue => {
  uploadImage(blobs[0]).then((url) => {
    const contentState = editorState.getCurrentContent();
    const newContentState = contentState.createEntity("image", "IMMUTABLE", { src: url });
    const entityKey = newContentState.getLastCreatedEntityKey();
    onChange(AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ""));
  });
  return "handled";
}, [editorState, onChange]);

return <Editor blockRendererFn={blockRendererFn} handleDroppedFiles={handleDroppedFiles} />

上記を組み合わせることで、画像の挿入および Drag & Drop による実装が可能になります。

編集内容の保存

最後に、簡単な編集内容の保存ロジックの実装を行います。内容を保存するための Utility として、convertToRaw , convertFromRaw メソッドを Draft.js は用意しているので、そのメソッドを使って保存機能を実装します。

import { convertToRaw, convertFromRaw } from "draft-js";
  
// 簡単のために localStorage に保存している。
// 実プロダクトの場合には、APIとの通信にここは挿し変わる。
const save = async (data: string) => {  
  window.localStorage.setItem("data", data);
}

// 簡単のために localStorage からの読み込みになっている。
// 実プロダクトの場合には、APIとの通信にここは挿し変わる。
const load = async () => {
  const data = window.localStorage.getItem("data");
  if(data) {
    return JSON.parse(data);
  }
  return undefined;
}

const onSave = async (contentState: ContentState) => {
  const object = convertToRaw(contentState);
  const data = JSON.stringify(object);
  await save(data);
}

const onLoad = async () => {
  const raw = await load();
  if(raw) {
    return EditorState.createWithContent(convertFromRaw(raw));
  }
  return EditorState.createEmpty();
}

この onSave や、 onLoad を、適宜必要なタイミングで読み込むことで保存や読み込みを実現できます。

まとめ

4つのエディターの機能の Draft.js による実装について説明しました。どの実装も基本的な流れは同じで、Editor コンポーネントの props に定義された関数を用いて、その中で EditorState を操作することで、エディターの開発が行えます。

上記の実装パターンを応用することで以下の機能も実装できます。

  • 画像を挿入するためのインサーターの実装
  • 区切り線の実装
  • サーバーと保存機能の繋ぎ込み
  • Ctrl+S による保存機能

発展 ~draft-js-plugnsにより責務を分割する~

上記の実装例をみると、将来的な機能開発で、 onChange などのハンドラーが肥大化する可能性があり、開発生産性を落とす可能性があります。

draft-js-plugins を用いることで、機能をプラグインに分けて実装することが可能になります。例えば、上記の例にあるインサーターのような機能をプラグインに分けることが可能です。(実装例

プラグインでは handleKeyCommand などの callback を利用することでき、このプラグインの中に閉じて開発を行うことが可能になります。以下が実際のプラグインの作成方法になります。

https://github.com/draft-js-plugins/draft-js-plugins/blob/master/HOW_TO_CREATE_A_PLUGIN.md

終わりに

エディターについての基本的な説明と、contenteditable による開発について、そして、Draft.js を用いた、WYSIWYGエディターの基本的な機能開発の実装について書きました。

基本的な機能ではありますが、EditorStateの操作や、Editor コンポーネントの props への繋ぎ込みによるエディターの機能開発の流れは、参考になるのではと思います。

次回は、 エディターを改善する上でのログの取得や Unsplash API と連携した画像の投稿など、より実践的なエディターの改善について書く予定です。

また、エディターの開発は奥が深く、Wantedly では、上記にあげた以外にも以下のような機能を Draft.js を使用して実現しています。

  • リスト表記、コード挿入など画像以外のコンテンツの挿入
  • Atomicな要素(画像など)のフォーカス管理
  • HTMLをコピーペーストした際のハンドリング
  • Unsplash や Embedly など外部APIと連携したリッチコンテンツの挿入
  • コンフリクト時の解消

また、共同編集機能やコメント機能など、 Wantedly のエディターにも、まだまだ改善したい部分が多く残っています。エディターの開発に興味があればぜひ一度お話しましょう!

Wantedly, Inc.'s job postings
31 Likes
31 Likes

Weekly ranking

Show other rankings
Invitation from Wantedly, Inc.
If this story triggered your interest, have a chat with the team?