リッチテキストエディタの概念と採用したEditor.jsの話

ogp

この記事は enechain Advent Calendar 2023 の24日目の記事です。

はじめに

こんにちは、enechain でフルスタックエンジニアをしている @tanaka-yui です。

enechain が提供している eCompass というプロダクトは、世界中のマーケット価格を分析しやすい形で視覚化し、タイムリーに提供するアプリケーションです。

価格だけでなく、電力動向の分析レポートやガスマーケット市場レポート、電力取引の制度に関するレポートなども提供しています。

今回の記事では、その eCompass でレポートの入稿のために採用したブロックスタイルエディタ「Editor.js」の話と、社内の帳票テンプレート生成エディタで作成した独自エディタの話をしようと思います。

リッチテキストエディタとは何かという解説をしながら、採用したエディタについて紹介したいと思います。

リッチテキストエディタについて

サービス内で文章を画面から登録して表示したいケースがあると思いますが、どう入った方法を考えるられるでしょうか。

いくつか実装方法があるかと思いますが、パターン例をあげると

  1. Wordpress、MovableType等のOSSを利用し構築し利用する
  2. CMSサービスを利用する
  3. Headless CMSを利用し、画面は独自で作成する
  4. WYSISYIGエディタなど、リッチテキストエディタライブラリを導入し登録画面を作成、表示面は独自
  5. TextareaやInput要素を用いて登録Formを作り、表示面も独自に作成する

などが考えられると思います。

ライブラリの種類

エディタのライブラリの種類としては大まかに以下のように分類されます。

  • WYSISYIG Editor

    What You See Is What You Getの略で、見たままになるエディタのことを指します。

    Microsoft Word や Google Docs などの文章の書き心地に近いものです。 上部に装飾ボタンがあり、文章を装飾できるエディタがこれにあたります。

  • Block Style Editor

    Medium や Notion などのような文章の書き心地に近いものです。ブロックごとに装飾のツールチップが表示され、装飾するような書き心地です。

サイトを作る時に昔から使われているWordpressは、V4系まではWYSISYIG Editorでしたが、V5系からは 「gutenberg」という Reactで作られたBlock Style Editorになりました。

v5以降も、Classic Editorというプラグインを入れることで、v4系のエディタを使うこともできます。

左がClassic Editor (WYSISYIG Editor)、右がgutenberg (Block Style Editor) です。

wordpress.png

生成されるデータ

主にHtml形式で保存されるものと、JSON形式で保存されるものがあります。

WYSISYIG Editorの代表的な、CKEditorTinyMCE は、Html形式です。

TinyMCEの場合

<p>あいうえお</p>
<p>かきくけこ</p>
<p>さしすせそ</p>

比較的近年開発されたWYSISYIG Editorの LexicalProseMirror はJSON形式です

Lexicalの場合

{
  "editorState": {
    "root": {
      "children": [
        {
          "children": [
            {
              "detail": 0,
              "format": 0,
              "mode": "normal",
              "style": "",
              "text": "あいうえお",
              "type": "text",
              "version": 1
            }
          ],
          "direction": "ltr",
          "format": "",
          "indent": 0,
          "type": "paragraph",
          "version": 1
        },
        {
          "children": [
            {
              "detail": 0,
              "format": 0,
              "mode": "normal",
              "style": "",
              "text": "かきくけこ",
              "type": "text",
              "version": 1
            }
          ],
          "direction": "ltr",
          "format": "",
          "indent": 0,
          "type": "paragraph",
          "version": 1
        },
        {
          "children": [
            {
              "detail": 0,
              "format": 0,
              "mode": "normal",
              "style": "",
              "text": "さしすせそ",
              "type": "text",
              "version": 1
            }
          ],
          "direction": "ltr",
          "format": "",
          "indent": 0,
          "type": "paragraph",
          "version": 1
        }
      ],
      "direction": "ltr",
      "format": "",
      "indent": 0,
      "type": "root",
      "version": 1
    }
  },
  "lastSaved": 1703123298582,
  "source": "Playground",
  "version": "0.12.5"
}

Block Style Editorでは、Editor.js は json形式です。NotionもAPI利用でき、json形式でデータを取得できます。

Editor.jsの場合

{
    time: 1703124230179,
    blocks: [
        {
            id: "mhTl6ghSkV",
            type: "paragraph",
            data: {
                text: "あいうえお"
            }
        },
        {
            id: "l98dyx3yjb",
            type: "paragraph",
            data: {
                text: "かきくけこ"
            }
        },
        {
            id: "os_YI4eub4",
            type: "paragraph",
            data: {
                text: "さしすせそ"
            }
        },
    ]
}

Block形式のエディタは、json形式でデータを保存することが多いですが、wordpressはWYSISYIG Editorから進化してきており、下位互換をたもつためか、Html形式で保存されています。

Classic Editorの場合

<strong>あいうえお</strong>
<p style="text-align: center;">かきくけこ</p>
さしすせそ

gutenbergの場合

<!-- wp:paragraph -->
<p><strong>あいうえお</strong></p>
<!-- /wp:paragraph -->

<!-- wp:paragraph {"align":"center"} -->
<p class="has-text-align-center">かきくけこ</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>さしすせそ</p>
<!-- /wp:paragraph -->

Classic Editorをblock形式に変換するメニューもあります

wordpress_change.png

他にも多くのエディタがありますが、JSONフォーマットで保存するエディタが増えている印象です。

理由としてはいくつかあると思いますが

  • 文章構造にタグを含むため容量がかさむ
  • WYSIWYGエディタのHTMLは、入力の方法によってDOMが汚くなる場合があり、表示する時に崩れる可能性がある
  • Reactなどでは通常サニタイズされるため、HTMLのデータを表示する場合 dangerouslySetInnerHTML を使う必要があるが、バリデーションやサニタイズを適切に行わないとXSS攻撃に繋がり、脆弱性が生じる危険性がある

などが考えられると思います。

Htmlで保存されたデータの場合、そのままHtmlを表示することもできますが、見た目をカスタムしたい場合、各種ライブラリで生成されたclass名などを上書きしてカスタムすることもできます。

JSON形式の場合、表示時にHtmlに変換する必要があるため、表示部分をライブラリとして提供しているものもあります。

Notionは Notion-X

ProseMirrorは ProseMirror-View

lexicalや、Editor.jsは表示部分を公式では提供していないため、自分で実装する必要があります。

そもそもWebのリッチテキストエディタのライブラリの作り

一言でいうと contenteditable="true" という属性をdivなどのタグに付与することでdom要素が画面上で編集可能になり、それがリッチテキストエディタとしての基本になります。

<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    </head>
    <body>
        <h2>contenteditableをtureにしただけの、WYSIWYGになる基本テキストエディタ</h2>
        <div contenteditable="true" style="border: 1px solid #000000; width:100%;height:200px;font-size:18px;"></div>
    </body>
</html>

左がChrome、右がFirefoxです。 contenteditable="true" にするとdivのブロックの中身をブラウザ上で編集できるようになり、入力した結果は以下のようになります。

contenteditable.png

エディタの基本はこれだけですが、各ライブラリなどはどんなことを行っているのでしょうか? contenteditableをtureにしただけでは、装飾ボタンなどはないため、各ライブラリは装飾ボタンを実装しています。 他にも画像挿入だったり様々な機能を実装しています。

あとは見た目からでは見えない要素もあります。

左の表示上の見た目は同じですが、青いカーソルの当たったElementをみてみましょう。左のChromeは、とくにdivで囲われてないけど、右のFirefoxはdivがついていますよね。

このように contenteditable="true" の要素で編集したものはブラウザの挙動によってい差異が生まるケースがあります。

他にも Chromeは、⌘+b (macの場合) で文字を太字にできますが、Firefoxはできません。

contenteditable_bold.png

こういったブラウザの違いを吸収し、生成されるDOMを統一することも、リッチテキストエディタが担っています。

CKEditorを例にあげますが、下記のようにdivで囲われていたものを、 タグの意味が文章の意味を持つようにpタグになるようにキーボードイベントを監視し、統一されるように実装されています。 他にもペースト処理などもブラウザによってかなり挙動が違うため、それを吸収する処理を実装しているエディタもあります。

ckeditor

採用したエディタについて

eCompassでの採用

eCompassでは、「Editor.js」を採用しました。

Notionや、Lexical、ProseMirrorも検討しましたが、Editor.jsを採用した理由は以下の通りです。

求めた要件

  • eCompassの画面の一部として組み込むことができる
  • もともとNotionで記事を書いていたので、UI/UXとしてNotionに近いものが良い
  • Notionの時のように、子ページを作ることができる

上記要件に対し、各ライブラリを比較しましたが

  • Notion
    • Pros
      • 子ページを作れる
      • 入稿する人がUI操作になれている
    • Cons
      • 子ページを作ることができるが、3階層、4階層と自由に作ることができ、Notion上で2階層目までみたいな制約を設けることが厳しい
      • JSON形式が再起的な構造で複雑なため、表示用のブロックの独自実装に工数がかかる
      • Notion-Xは、サーバーを立てる必要があり、eCompassの画面の一部として組み込むことが厳しい
  • Lexical、ProseMirror
    • Pros
      • JSONがシンプル
      • 表示用のブロックの独自実装が自由
      • eCompassの画面の一部として組み込むことができる
    • Cons
      • WYSIWYG Editorのであり、Notionのようなブロックスタイルではないため、子ページブロックという概念を作りづらい
  • Editor.js
    • Pros
      • JSONがシンプル
      • 表示用のブロックの独自実装が自由
      • eCompassの画面の一部として組み込むことができる
      • ブロックエディタのため、子ページブロックという概念を作りやすい
    • Cons
      • ブロック内部の装飾に一部DOM(bタグなど)が使われているため、パースする際に注意が必要

と、Editor.jsが一番求めていた要件に近いため、採用しました。

下記の図の様な形で実装しています。

  • Viewのブロック部分は全て自作する必要があったため自作
  • Editのブロック部分はプラグインとして提供されていないChildPage、File, Imageはカスタムブロックとして実装
  • Paragraphは標準のプラグインだとコピー&ペーストの動きが想定と異なったため自作
  • 一部装飾部分などをパースする必要があったが、React向けに作成されたhtmlパーサーが存在したため、それを利用

ecompass.png

Imageブロックの実装例
  • Viewブロック
const Image = ({ data }: BlockData<ImageOutputData>): JSX.Element => {
    // 一部省略

    return (
        <Box position="relative" mb={2}>
        {isFetching && (
            <Box position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)">
                <Spinner />
            </Box>
        )}
        {res && <ChakraImage src={res?.url} alt={data?.caption} w="full" />}
        </Box>
    )
}

export default Image
  • Editブロック
export default class SimpleImage implements BlockTool {
    private api: any
    private blockIndex: number
    private nodes: any
    private CSS: any
    private _data: any
    private token?: string
    private settings?: any

    constructor({ data, api, config }: SimpleImageOptions) {
        this.api = api
        this.blockIndex = this.api.blocks.getCurrentBlockIndex() + 1
        this.nodes = {
            wrapper: null,
            imageHolder: null,
            image: null,
            caption: null,
        }

        this.CSS = {
            baseClass: this.api.styles.block,
            loading: this.api.styles.loader,
            input: this.api.styles.input,

            wrapper: 'cdx-simple-image',
            imageHolder: 'cdx-simple-image__picture',
            caption: 'cdx-simple-image__caption',
        }

        if (!config?.token) {
            console.error('apiClient token is required')
            return
        }

        this.token = config?.token

        this.data = {
            filename: data.filename || '',
            url: data.url || '',
            caption: data.caption || '',
        }
    }

    static get toolbox() {
        return {
            title: 'Image',
            icon: '<i class="fa-solid fa-image"></i>',
        }
    }

    static get isReadOnlySupported() {
        return true
    }

    render() {
        this.CSS = {
            baseClass: this.api.styles.block,
            loading: this.api.styles.loader,
            input: this.api.styles.input,
            settingsButton: this.api.styles.settingsButton,
            settingsButtonActive: this.api.styles.settingsButtonActive,
            wrapper: 'cdx-simple-image',
            imageHolder: 'cdx-simple-image__picture',
            caption: 'cdx-simple-image__caption',
        }

        const wrapper = this._make('div', [this?.CSS?.baseClass || '', this?.CSS?.wrapper || ''])

        if (this.data?.filename) {
            wrapper.appendChild(document.createTextNode(this.data.filename))
            this.nodes.wrapper = wrapper
            return wrapper
        }

        const loadButtonWrapper = this._make('div')
        let loadButton = this._make('input', [], {
            type: 'file',
        })
        loadButtonWrapper.appendChild(loadButton)

        wrapper.appendChild(loadButtonWrapper)

        this.nodes.wrapper = wrapper
        this.nodes.loadButton = loadButton

        const token = this.token
        if (!token) return wrapper

        loadButton.onchange = async (e) => {
            const file = e.target.files[0]
            if (!file) return
            const fileInfo = await uploadFile(token, {
                file,
            })
            if (!fileInfo) return console.error('Failed to upload image')
            this.data = {
                filename: file.name || '',
                url: fileInfo.filePath,
                caption: file.name || '',
            }

            loadButton.remove()
            loadButton = null
            wrapper.appendChild(document.createTextNode(this.data.filename))
        }

        return wrapper
    }

    save() {
        return this.data
    }

    static get sanitize() {
        return {
            filename: {},
            url: {},
            caption: {
                br: true,
            },
        }
    }

    onPaste(event) {
        switch (event.type) {
            case 'tag':
                this.data = {
                    url: event.detail.data.src,
                }
                break
            case 'pattern':
                this.data = {
                    url: event.detail.data,
                }
                break
            case 'file':
                this.data = {
                    url: URL.createObjectURL(event.detail.file),
                    caption: this.data.caption || event.detail.file.name,
                }
                break
        }

        this.nodes.loadButton.remove()
        this.nodes.loadButton = null
    }

    get data() {
        return this._data
    }

    set data(data) {
        this._data = Object.assign({}, this.data, data)

        if (this.nodes.image) {
            this.nodes.image.src = this.data.url
        }

        if (this.nodes.caption) {
            this.nodes.caption.text = this.data.caption
        }
    }

    _make(tagName, classNames?: string[], attributes = {}) {
        const el = document.createElement(tagName)

        if (Array.isArray(classNames)) {
            el.classList.add(...classNames)
        } else if (classNames) {
            el.classList.add(classNames)
        }

        for (const attrName in attributes) {
            el[attrName] = attributes[attrName]
        }

        return el
    }
}

帳票テンプレート生成エディタについて

こちらのエディタは、全てReactで完全自作です。

  • 社内で利用できるAPIの提供
  • 帳票フォーマットが増えた場合でも、画面からフォーマットを登録、メンテナンスできるようにする

という設計思想で作成しました。

特別なライブラリは用いず、Formの組み合わせです。

生成したフォーマットの属性にキーを設定し、そののままAPIのリクエストボディに設定できる様に作成しています。

  • フォーマット生成時ではなく、利用側からAPI呼び出し時に値を挿入し、その値を特定のレイアウトで表示できる必要がある

という要件を叶えようとすると、文章構造を作成するリッチテキストエディタでは自由すぎるため、ある程度フォーマットが制限されておりかつフォーマットの内容をカスタムできる必要があったこと。利用側から値を特定の位置に値を挿入できること。以上の理由からFormを組み合わせて独自エディタを作成することにしました。

editor1.png editor2.png

さいごに

今回はEditorを作成するというところにフォーカスしましたがいかがでしたでしょうか。

なかなかエディタを作成する、というケースはたくさんあるわけではないと思いますが、いざ作成が必要という場面に遭遇したとき、エディタの利用方法は検索すると出てくるが、特徴やニーズに合ったエディタを選ぶ方法がなかなか見つからないということがあると思います。

そういったケースの参考になれば幸いです。

最後まで読んでいただき、ありがとうございました。

Advent Calendar 2023の最後 25日は Yusuke Suto | enechain の記事です。お楽しみに。

enechainでは、共にプロダクトを共創していく仲間を募集しています。要項は以下からご確認ください!

herp.careers