こんにちはjunです。今回の記事は気になっているブラウザテキストエディターであるeditor.jsについての記事です。WYSINGの一種ですが、シンプルながらもckeditorやtinymceに変わるエディターになるのでは?と思いながら、カスタムブロックを作成しましので記事にしたいと思います。
なぜeditor.jsなのかという経緯や背景から解説しますので、さっさとタイトルの実装内容を知りたい場合は「今回作るもの」からご覧ください。
webシステムを作る際にはプレーンテキストのサポートだけでなく、リッチテキストエディタをサポートして欲しいという声があります。リッチテキストがあれば太字、リンク、色、画像などをhtmlを知らずともリッチなコンテンツをワードを使うようにユーザーが実装できるようになります。(こんなやつです↓)
 
ワードで作るような文章ベースの内容であればckeditorやtinymceで問題はありませんが、以下のようなデメリットや要件での難しさがあります。
「構造化したデータ、特定のフォーマット」というのは表示したいビューコンポーネントに対する入力項目のことです。例えば以下のbootstrapのカードを見てみてください。
 
お客様が「このカードを自分達でテキスト、画像を選択して任意のページ、箇所で表示できるようにしたい」というめんどうな要件があったとします。自由な入力をサポートするので、管理画面での固定的なフォームでは難しそうです。しかし、リッチテキストでは画像の選択や、テキスト部分の制御、カード自体の表示と削除など難しと思います。
さらに以下のようなアコーディオンはどうでしょうか?
 
タイトル、テキスト意外にも「複数個」入力しないといけない、1つのコンポーネントにn個のフォーマットデータを入力できるようにする必要があります。
これが「構造化したデータ、特定のフォーマットにしたがった入力やパーツを表現、制御UI」でしたり、「複雑なビューを見たまま編集する」が難しいという意味です。wordpressでもグーデンベルクというコンポーネントレベルで記事を作成することが主流になり、webコンテンツの編集はワードで作成する様な文章的な内容からリッチなビューをサポートするのが要件になりつつあります。
その場合、従来のWYSINGでは実装が難しいです。そのためWYSINGでHTMLを作成してそれを保存、表示するのではなくて構造化されたデータをベースに表示、編集、出力できるようにしたものがeditor.jsです。
詳細は公式サイトを参照ですがざっとあげるとすれば
といった感じです。取得されるデータは以下のようになっています。
{
    "time" : 1654313680224,
    "blocks" : [
        {
            "id" : "gy0oTOBqL2",
            "type" : "header",
            "data" : {
                "text" : "Editor.js",
                "level" : 2
            }
        },
        {
            "id" : "AOOa_CMrPW",
            "type" : "list",
            "data" : {
                "style" : "unordered",
                "items" : [
                    "It is a block-styled editor",
                    "It returns clean data output in JSON",
                    "Designed to be extendable and pluggable with a simple API"
                ]
            }
        },
        {
            "id" : "6crsAbriK8",
            "type" : "header",
            "data" : {
                "text" : "What does it mean clean data output",
                "level" : 3
            }
        },
        {
            "id" : "GBhduZdsZW",
            "type" : "image",
            "data" : {
                "file" : {
                    "url" : "https://codex.so/public/app/img/external/codex2x.png"
                },
                "caption" : "",
                "withBorder" : false,
                "stretched" : false,
                "withBackground" : false
            }
        }
    ]
}
blocksという要素はいかにそれぞれのブロックのデータが入り、typeによって種類を識別して決まったプロパティーが取得されます。
メリットは従来のwysingで管理できなかったような複雑なビューや構造化したデータをクライアント側で簡単に入力できるようになったこと、そしてそのデータをJSONで管理できることです。スキーマを定義してバリデーションもしやすいですし、JSONなので配布もしやすいです。ブロックのカスタマイズや作成も簡単に行えるので、拡張性も高いです。今回は「作成」のみ行います。
デメリットは取得されるデータがJSONのため、レンダリングをしたい際は専用のパーサを作成する必要がります。node.jsであればeditorjs-htmlというものを使用したりして自前でhtmlに変換する処理が必要です。基本的にはblocksをforeachして、typeをswitchにて分岐させて特定のhtml文字列を作成するといった感じです。
また当たり前ですがバリデーションも実装する必要があります。従来のwysingではphp-purifier,html_sanitizerなどを使用して特定のタグや属性をフィルタして保存するなどを行います。editor.jsは構造化したデータが大切になるので、永続化処理時にあらかじめ定義したプロパティーであるか、構造であるかをチェックするシステムは自前で実装する必要があります。(ここはまた今度書く予定です。)
まだ公式から言語ごとのパーサーやバリデーターはまだ安定したものが出ていない印象です。(2022年6月当時)
上記のような特徴を持ったeditor.jsですが、今回は例に出したアコーディオンを作ってみようかと思います。タイトルと本文(太字、斜体、リンクをサポート)を複数個に増減可能で、順番も変えられるようにします。
使う場面としては「よくある質問」みたいなページです。editor.jsの説明をメインに行うため、デザインや動きは最低限となっています。バニラJSで作成し、サンプルはこちらに置いておきました。
今回はindex.html,app.js,style.cssの3つだけを使用します。editor.jsはCDNから引っ張り、webpackなどは使用しません。
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <title>editorjs</title>
    <link rel="stylesheet" href="./style.css">
</head>
<body>
    <div class="container">
        <div class="mt-5 border bg-light">
            <div id="editorjs"></div>
        </div>
        <div class="mt-2">
            <pre id="data"></pre>
        </div>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@latest"></script>
<script src="./app.js"></script>
</html>
editor.js側はひとまず以下のようにして、<div id="editorjs"></div>にエディターがマウントされるようにします。
const editor = new EditorJS({
    holder: 'editorjs',
});
.ce-block__content{
    background-color: white;
}
マウントされると以下の画像のように、editor.jsがマウントされて初期状態ではテキストブロックだけが入力できます。
 マウントが完了したらブロックを作成していきましょう。
マウントが完了したらブロックを作成していきましょう。
実装にはeditor.jsのドキュメントを参考にして解説します。ドキュメントの方も合わせて作ってみることをお勧めします。
editor.jsにてブロックを実装する場合以下のメソッドを実装します。
class Accordion{
    // editor jsにブロックの情報を渡す
    static get toolbox() {
        return{
            title: 'YourBlockName',
            icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M528 32H144c-26.51 0-48 21.49-48 48v256c0 26.51 21.49 48 48 48H528c26.51 0 48-21.49 48-48v-256C576 53.49 554.5 32 528 32zM223.1 96c17.68 0 32 14.33 32 32S241.7 160 223.1 160c-17.67 0-32-14.33-32-32S206.3 96 223.1 96zM494.1 311.6C491.3 316.8 485.9 320 480 320H192c-6.023 0-11.53-3.379-14.26-8.75c-2.73-5.367-2.215-11.81 1.332-16.68l70-96C252.1 194.4 256.9 192 262 192c5.111 0 9.916 2.441 12.93 6.574l22.35 30.66l62.74-94.11C362.1 130.7 367.1 128 373.3 128c5.348 0 10.34 2.672 13.31 7.125l106.7 160C496.6 300 496.9 306.3 494.1 311.6zM456 432H120c-39.7 0-72-32.3-72-72v-240C48 106.8 37.25 96 24 96S0 106.8 0 120v240C0 426.2 53.83 480 120 480h336c13.25 0 24-10.75 24-24S469.3 432 456 432z"/></svg>'
        }
    } 
    constructor({ data,api,config }){
    }
    render(){}
    save(blockContent){}
}
とりあえずこれら3つがあればブロックは実装できます。static get toolbox()はeditor.jsが呼び出し、ツールボックスのアイコンやブロック名の表示を行います。アイコンはsvgのタグまたは、画像タグを指定します。widthとheightは50ぐらいが適切です。
コンストラクターにはeditor.jsより、このブロックの初期データdata、とeditor.jsのコアにアクセスできるapiが渡されます。
render()はブロックが初期化された時に最初に呼び出され、ブロックのUIビューのhtmlを返すようにします。
save(blockContent){}はエディターの保存時に呼ばれ、このブロッククラスの値を出力します。引数のblockContentはrender()にて渡されたDOMです。このメソッドはオブジェクトを返すようにします。上記の例でいう、dataの箇所を出力します。
{
    "id" : "6crsAbriK8",
    "type" : "header",
    // save で出力する
    "data" : {
        "text" : "What does it mean clean data output",
        "level" : 3
    }
},
とりあえず上記の4つのメソッドはブロックを作成する上で重要なものになりますので、ひとまず把握しておいてください。
とりあえずEditor.js上でブロックを選択できるようにしましょう。上記のクラスとメソッドを実装してDOMにマウントします。
class Accordion{
    // editor jsにブロックの情報を渡す
    static get toolbox() {
        return{
            title: 'アコーディオン',
            icon: 'svg' // 長いので省略
        }
    } 
    constructor({ data,api,config }){
    }
    render(){}
    save(blockContent){}
}
const editor = new EditorJS({
    holder: 'editorjs',
    tools: { 
        accordion : {class:Accordion,inlineToolbar: true}
    }
});
holderのIDに指定したDOMにEditor.jsがマウントされます。そしてtoolsに追加したいカスタムブロックのクラスを指定します。後でも解説しますが、インラインツールバーという斜体や太字などを使用できるようにする場合はinlineToolbar:trueを指定します。
 
この時にブロックをリストから選択した時にeditor.jsにマウントされます。この時にrender()でreturnされたElementがマウントされます。まずは要素を追加するボタンを用意しましょう。
ブロックを追加したい際にまずブロックのconstructorが呼ばれます。そしてrender()でreturnされたElementがマウントされます。今は追加ですが、後ほど保存した値からブロックをレンダーする処理を追加することもあるので、constructorで処理処理を記述します。
class Accordion{
    static get toolbox() {
        return {
          title: 'アコーディオン',
          icon: 'svg'
        };
    }
    constructor({data,config,api}){
        const accordionData = data.itmes || [];
        this.createParentWrapper();
    }
    render(){}
    createParentWrapper(){}
}
createParentWrapper()では最終的にアコーディオンのDOMを作成します。
createParentWrapper(){
    const wrapper = document.createElement("div");
    const parentId = "parent-" + this.randomID();
    wrapper.classList.add("accordion");
    wrapper.id = parentId;
    wrapper.innerHTML = `
    <button class="accordion-add btn btn-success d-block btn-sm">
    追加する
    </button>
    `;
    this.wrapper = wrapper;
}
こうすると追加するボタンが追加されたはずです。
 
この追加するボタンを押したらアコーディオンの要素が追加されるようにします。
randomID(){
    let result           = '';
    let characters       = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
    let charactersLength = characters.length;
    for ( let i = 0; i < 10; i++ ) {
        result += characters.charAt(Math.floor(Math.random() * 
        charactersLength));
    }
    return result;
}
createParentWrapper(){
    const wrapper = document.createElement("div");
    const parentId = "parent-" + this.randomID();
    wrapper.classList.add("accordion");
    wrapper.id = parentId;
    wrapper.innerHTML = `
    <button class="accordion-add btn btn-success d-block btn-sm">
    追加する
    </button>
    `;
    wrapper.querySelector(".accordion-add").addEventListener("click",()=>{
        this.addItemEle();
    });
    this.wrapper = wrapper;
}
addItemEle(title="",content=""){
    this.wrapper.insertBefore(this.generateItemEle(title,content),this.wrapper.querySelector(".accordion-add"))
}
generateItemEle(title,content){}
addItemEle()を呼ぶことでアイテムのDOMが挿入されるようにします。DOM自体はgenerateItemEle()で生成されるようにします。
次はgenerateItemEle()で生成されるDOMの記述を作成します。
generateItemEle()ではdocument.createElementを使用してbootstrap通りのHTMLを作成します。titleとcontentではリッチテキストを挿入するためにinnerHTMLで入れています。実際に表示する際はXSSの危険があるので入力値の検証が必要です。
generateItemEle(title,content){
        const itemWrapper = document.createElement("div");
        const wrapperId = this.randomID();
        itemWrapper.id = wrapperId;
        itemWrapper.classList.add("accordion-item");
        const itemHeaderWrapper = document.createElement("h2");
        itemHeaderWrapper.classList.add("accordion-header");
        const collapseId = wrapperId+"-collapse";
        itemHeaderWrapper.innerHTML = `
        <button class="accordion-button" type="button" data-bs-toggle="collapse">
            <div class="accordion-button-wrapper">${title}</div>
        </button>
        `;
        itemHeaderWrapper.querySelector('.accordion-button-wrapper').contentEditable = true;
        const itemBodyWrapper = document.createElement("div");
        itemBodyWrapper.classList.add("accordion-collapse","collapse","show");
        itemBodyWrapper.id = collapseId;
        itemBodyWrapper.innerHTML = `
        <div class="accordion-body">
            <p class="accordion-body-input">${content}</p>
            <div class="mt-2">
            <button class="accordion-delete btn btn-danger btn-sm">削除</button>
            </div>
        </div>
        `;
        itemBodyWrapper.querySelector('.accordion-body-input').contentEditable = true;
        itemBodyWrapper.querySelector('.accordion-delete').addEventListener('click',()=>{
            if(window.confirm("削除してもよろしいですか?")) itemWrapper.remove();
        });
        itemWrapper.appendChild(itemHeaderWrapper);
        itemWrapper.appendChild(itemBodyWrapper);
        return itemWrapper;
        /*
        生成イメージ
        <div class="accordion-item">
            <h2 class="accordion-header" id="headingOne">
                <button class="accordion-button" type="button" data-bs-toggle="collapse" >
                    <div class="accordion-button-wrapper">${title}</div>
                </button>
            </h2>
            <div id="${wrapperId}-collaps" class="accordion-collapse collapse show">
                <div class="accordion-body">
                    <p class="accordion-body-input">${content}</p>
                    <button class="accordion-delete btn btn-danger mt-2 btn-sm">
                    削除
                    </button>
                </div>
            </div>
        </div>
        */
    }
そしてアイテムのHTMLを返すようにし、wrapperに挿入します。ここまで実装し、ボタンをクリックした時に以下のようにアコーディオンが表示されます。
 
またcontentEditableをtrueにすることで、
itemHeaderWrapper.querySelector('.accordion-button-wrapper').contentEditable = true;
itemBodyWrapper.querySelector('.accordion-body-input').contentEditable = true;
以下の図のように文字の入力がinputなしでできるようになります。
 
そして以下のように削除ボタンにイベントリスナーを実装することで要素の削除ができるようになります。
itemBodyWrapper.querySelector('.accordion-delete').addEventListener('click',()=>{
    if(window.confirm("削除してもよろしいですか?")) itemWrapper.remove();
});
保存ボタンをクリックした時に入力した内容を出力できるようにします。
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
    <title>editorjs</title>
    <link rel="stylesheet" href="./style.css">
</head>
<body>
    <div class="container">
        <div class="mt-5 border bg-light">
            <div id="editorjs"></div>
        </div>
        <!-- 追加 -->
        <div class="mt-2"> 
            <button id="save" class="btn btn-primary">保存&出力</button>
            <pre id="data"></pre>
        </div>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@latest"></script>
<script src="./app.js"></script>
</html>
const pre = document.getElementById("data");
document.getElementById("save").addEventListener("click",()=>{
    editor.save().then((outputData) => {
        pre.textContent = JSON.stringify(outputData, null , "\t");
    }).catch((error) => {
        console.log('Saving failed: ', error)
    });
})
editor.jsでブロックのデータを出力したい時はeditor.save()を使用します。Promiseなので非同期でデータが出力されます。今回はJSONで出力するようにしました。デモサイトで確認できます。editor.save()では全ブロッククラスのsave()が呼ばれるため、Accordionクラスでもsave()メソッドを実装します。
save(blockContent){
    const contents = blockContent.querySelectorAll(".accordion-item");
    const itmes = Array.prototype.map.call(contents, (item)=>{
        const title = item.querySelector(".accordion-button-wrapper");
        const content = item.querySelector(".accordion-body-input");
        return {
            title:title.innerHTML || "",
            content:content.innerHTML || "",
        };
    })
    return {
        itmes
    }
}
blockContentはrender()で表示しているwrapperのDOMが渡されます。今回のクラスの場合、this.wrapperとほぼ同じです。save()で行うことはブロックのDOMからアコーディオンのタイトルと内容を取得します。複数個のアコーディオン要素があるのでblockContent.querySelectorAll(".accordion-item")でNodeListを取得し、.accordion-button-wrapperと.accordion-body-inputのinnnerHtmlを取得して、save()で返したいオブジェクトに当てはめます。
return {
    items
};
// ↓
// return {
//     items:[
//         {
//             title:"...",
//             content:"...",
//         },
//         {
//             title:"...",
//             content:"...",
//         },
//         {
//             title:"...",
//             content:"...",
//         },
//         ...
//     ]
// };
デモサイトでアコーディオンを作成し、文字を入力して「保存・出力」をクリックすると上記のオブジェクトをJSON化したものが表示されると思います。
 
冒頭でinlineToolbar: trueを指定するとリッチテキストを使用できるようになり、文章に対して太字、斜体、リンクを付与するツールが出現するようになります。
 
リッチテキストを保存、再現する場合はinnetHtmlをget、setする必要があります。
上記まではフォームを表示し、エディタ上で文章を入力して、保存・出力しました。しかし実際の用途では保存したデータから再度エディタ上に表示できるようにしましょう。
エディタにデータを渡す時はEditorクラスにdataプロパティーに出力したデータを設定すれば大丈夫です。
const editor = new EditorJS({
    holder: 'editorjs',
    tools: { 
        accordion : {class:Accordion,inlineToolbar: true}
    },
    // ここ
    data:JSON.parse('出力したデータ')
});
JSONで保存した時はパースでします。JSON内のblocks内の各ブロックのtypeから判別してブロッククラスを当てはめてくれます。そしてこのデータはブロッククラスのコンストラクタにdataという引数で渡されます。
class Accordion{
    constructor({data,config,api}){
        // これ!
        const accordionData = data.itmes || [];
        this.createParentWrapper();
        // データ当てはめ
        if(accordionData.length > 0){
            accordionData.forEach(element => {
                this.addItemEle(element.title,element.content);
            });
        }
    }
}
そしてデータが存在する場合、data.itmesをforeachでthis.addItemEle()を通じてフォームのDOMを生成します。
Editor.jsにおける反復入力できるブロックの作成方法は以上となります。editor.jsでのブロック作成の基本は
render()でブロックのDOMを返す。constructor()で初期処理を行う。save()でブロックのDOMから値を取得してオブジェクトで返す。以上となります。簡単のブロックの場合、render()にgenerateItemEle()で作成したようなDOM生成のコードを書いてもいいですが、今回は要素の追加・削除が必要なためメソッドを分けました。
ただしgenerateItemEle()の中を見ると結構ごちゃごちゃしています。vue.jsやreact.jsに慣れた人にとってみると、saveでの値の取得が面倒に感じるかもしれません。DOMの生成部分は正直undersocre.jsのtemplateなどを使用してデータバインディングを使用した方がより複雑なブロックを作成できると思います。今度はundersocre.jsと組み合わせて複雑な入力形態をとれるようにしてみます。
今回作成したコードはデモサイトからご確認ください。