Movable Type 7のBlockEditorに独自フィールドを追加する

公開

Movable Type 7のコンテンツタイプでは、複数行テキストフィールドにおいて「BlockEditor(ブロックエディタ)」が利用できます。ブロックエディタとは、見出し・段落・画像など入力するコンテンツに合わせたブロックエディタフィールドを選択して入力する作業を繰り返し一つのコンテンツページを作り上げていく仕組みです。TinyMCEなどのWYSIWYGエディタでは難しいレイアウトでも、ブロックエディタであれば予め入力フィールドを整えておくことできれいにレイアウトが再現できる可能性を持っています。

現在公開されているMovable Type 7 Betaでは標準で4種類のフィールドが選択できますが、本格的に使用するには段落をはじめ様々なフィールド(フォーマット)が必要になるでしょう。そこで、ブロックエディタのフィールドを追加する方法を研究してみました。よく利用する「画像+テキスト」フィールドを題材とするのが良いかと思いましたが、見栄えのよい「Googleマップフィールド」を作成してみることにしました。(ちなみにMT4時代(2009年頃)には管理画面とカスタムフィールドを駆使してGoogleマップを組み込んでいました。記事「Movable Typeの記事投稿画面にGoogle Mapsを表示」に記録しています。)

※2018年3月7日追記:「画像+テキスト」フィールドに関する記事「Movable Type 7のBlockEditorに「画像+テキスト」のフィールドを追加する」を公開いたしました。

完成した画面は以下の通りです。
画面キャプチャ:コンテンツタイプ編集画面のブロックエディタでGoogleマップにより緯度・経度を入力可能にした画面

config.yamlの設定

Googleマップフィールドを追加する単独プラグインとして作成したいところですが、Perlの処理が上手く書けずに苦戦したためひとまずBlockEditorプラグインをカスタマイズすることとしました。以降、全てプラグインのファイルとして用意したいが上手くいかないので標準で用意されているファイルをカスタマイズしたという前提でお読み頂ければと思います。

まず/path/to/mt/plugins/BlockEditor/config.yamlにGoogleマップフィールドの情報を追記します。(途中を省略しています。)

blockeditor_fields:
  embed:
    label: 'Embed'
    order: 40
  googlemap:
    label: 'Google Map'
    order: 60

2018/3/3 8:30追記

/path/to/mt/plugins/GoogleMapBlockFieldディレクトリを作成し、以下のようにconfig.yamlを書くことでプラグイン化できました! callbacksは編集画面のテンプレートを加工する必要がない場合は記述する必要はありません。

id: GoogleMapBlockField
name: GoogleMapBlockField
version: 1.0
author_link: https://www.anothersky.pw/
author_name: Hideki Abe
description: <MT_TRANS phrase="Add GoogleMap block field.">

callbacks:
  MT::App::CMS::template_param.edit_content_data: $GoogleMapBlockField::GoogleMapBlockField::App::_add_js_css

blockeditor_fields: 
  googlemap:
    label: 'GoogleMap'
    order: 50
    path: "plugins/GoogleMapBlockField/js/googlemap.js"

Maps JavaScript APIのロード

/path/to/mt/plugins/BlockEditor/tmpl/editor.tmplにscript要素を追加しhttps://maps.googleapis.com/maps/api/js?key=YOUR_API_KEYをロードします。

段落や画像+テキストフィールドであれば通常この作業は不要です。独自にCSSを追加したい場合はこのテンプレートに追記すると良いでしょう。

スクリプトの設計

標準で用意されている/path/to/mt-static/plugins/BlockEditor/lib/js/fields/header.jsを参考に作成してみました。

画像の準備

フィールド名の前に丸で囲まれたアイコンがありますが、これはSVGスプライトで表示されています。/path/to/mt-static/images/sprite.svgにGoogleマップのピンを模したアイコンを追加し、ic_mapで呼び出して利用できるようにしました。

create()にフィールドのHTMLを記述

ブロックを選択するとcreate関数の内容に従いフィールドが表示されるようです。そこで、Googleマップの表示に必要な要素と緯度・経度・ズームレベルの入力フィールドを用意しました。また、Googleマップの動作に必要な処理もここで呼び出すようにしました。

ブロックエディタの場合、同じ種類のフィールドが繰り返し利用される可能性があります。createの引数idにフィールド毎のIDが格納されていますので活用すると良いでしょう。

入力済みのデータもここでフィールドにセットするのですが、エスケープが必要かもしれないと考えました。(headerに</textarea><script>alert('hoge');</script><textarea>を入力すると...。)今回はparseInt()parseFloat()で文字を落とすことにしました。

get_data()に保存するデータを記述

データの保存はget_data関数の内容に従い処理が行われるようです。あくまでも「複数行テキストフィールド」を編集する「ブロックエディタ」なので、HTMLを生成することが前提となります。

header.jsを分析すると以下のようにデータが保存されています。

value
テキストフィールドに入力されたままの値
elem
選択した見出しレベル
html
出力されるHTML(valueの前後にhxタグが付いた状態)

Data API 4.0で/path/to/mt/mt-data-api.cgi/v4/sites/:site_id/contentTypes/:content_type_id/data/:content_data_idにアクセスすると、以下のような出力になります。

{
    "author": {
        "displayName": "*****",
        "userpicUrl": null
    },
    "basename": "7f8932299ac80f831fa6a738cae7be9cb024424e",
    "blog": {
        "id": "1"
    },
    "createdDate": "2018-03-01T21:28:34+09:00",
    "data": [
        {
            "data": "<h2>アクセス</h2>\n",
            "id": "12",
            "label": "コンテンツ",
            "type": "multi_line_text"
        }
    ],
    "date": "2018-03-01T21:27:10+09:00",
    "id": 6,
    "label": "所沢航空発祥記念館",
    "modifiedDate": "2018-03-02T07:50:36+09:00",
    "permalink": "http://mt7.localhost/",
    "status": "Publish",
    "updatable": false
}

HTMLを生成することが必須と思われたため、<mt-googlemap>要素を作成して緯度・経度・ズームレベルを格納することにしました。また、valueには<mt-googlemap>要素を付加しないデータを格納しました。latlng等のキーで格納しても良いのですが、今回はvalueにまとめて入れておきました。

完成した画面とスクリプト

完成した画面とスクリプト(googlemap.js)を示します。複数の地図の表示にも対応できています。
画面キャプチャ:ブロックエディタで複数のGoogleマップフィールドを表示している画面

; (function ($) {
    var BEF = MT.BlockEditorField;
    BEF.GoogleMap = function () { BEF.apply(this, arguments) };
    $.extend(BEF.GoogleMap, {
        label: trans('GoogleMap'),
        type: 'googlemap',
        svg_name: 'ic_map',
        create_button: function () {
            return $('<button type="button" class="btn btn-contentblock"><svg title="' + this.label + '" role="img" class="mt-icon"><use xlink:href="' + StaticURI + 'plugins/GoogleMapBlockField/images/sprite.svg#ic_map"></use></svg>' + this.label + '</button>');
        },
        get_svg: function() {
            return '<svg title="' + this.type + '" role="img" class="mt-icon mt-icon--sm"><use xlink:href="' + StaticURI + 'plugins/GoogleMapBlockField/images/sprite.svg#' + this.svg_name + '" /></svg>';
        },
    });
    $.extend(BEF.GoogleMap.prototype, BEF.prototype, {
        map: null,
        marker: null,
        get_id: function () {
            return this.id;
        },
        get_label: function (){
            return BEF.GoogleMap.label;
        },
        get_type: function () {
            return BEF.GoogleMap.type;
        },
        get_icon: function () {
            return BEF.GoogleMap.get_svg();
        },
        _mapInit: function ($map, json, isDraggable) {
            const self = this;
            const id = self.id;
            let mappingPoint;

            if (json) {
                mappingPoint = { lat: parseFloat(json.lat), lng: parseFloat(json.lng) };
            } else {
                mappingPoint = { lat: 35.681167, lng: 139.767052 };
            }

            self.map = new google.maps.Map($map[0], {
                zoom: json ? parseInt(json.zoom) : 10,
                center: mappingPoint,
            });
            self.marker = new google.maps.Marker({
                position: mappingPoint,
                map: self.map,
                draggable: !!isDraggable,
            });

            self.marker.addListener('dragend', function() {
                const point = self.marker.getPosition();
                $("#" + id + " input.lat").val(parseFloat(point.lat()));
                $("#" + id + " input.lng").val(parseFloat(point.lng()));
                $("#" + id + " input.zoom").val(parseInt(self.map.zoom));
                self.map.setCenter(self.marker.getPosition());
            });
            self.map.addListener('zoom_changed', function() {
                $("#" + id + " input.zoom").val(parseInt(self.map.zoom));
            });
        },
        _geocoder: function () {
            const self = this;
            const id = self.id;
            const address = $('#' + id + '_address').val();
            const geocoder = new google.maps.Geocoder();
            geocoder.geocode({ 'address': address }, function (results, status) {
                if (status === 'OK') {
                    const point = results[0].geometry.location;
                    self.map.setCenter(point);
                    self.marker.setPosition(point);
                    $("#" + id + " input.lat").val(parseFloat(point.lat()));
                    $("#" + id + " input.lng").val(parseFloat(point.lng()));
                    $("#" + id + " input.zoom").val(self.map.zoom);
                } else {
                    alert('Geocode was not successful for the following reason: ' + status);
                }
            });
        },
        create: function (id, data) {
            const self = this;
            self.id = id;
            self.data = data;

            self.view_field = $('<div class="form-group"></div>');
            const $map = $('<div id="map_' + id + '" style="width: 400px; height: 300px;"></div>');
            self.view_field.append($map);

            $(window).one('field_created', function () {
                self._mapInit($map, JSON.parse(self.data.value), 0);
            });

            return self.view_field;
        },
        get_edit_field: function () {
            const self = this;
            let json;
            self.$edit_field = $('<div class="edit_field form-group"></div>');

            const fieldHTML = [
                '<div class="row no-gutters py-2"><div class="col"></div>',
                '<div id="' + this.id + '">',
                '<div class="form-group"><label for="' + this.id + '_lat">緯度</label><input type="text" name="' + this.id + '_lat" id="' + this.id + '_lat" mt:watch-change="1" class="lat form-control" /></div>',
                '<div class="form-group"><label for="' + this.id + '_lng">経度</label><input type="text" name="' + this.id + '_lng" id="' + this.id + '_lng" mt:watch-change="1" class="lng form-control" /></div>',
                '<div class="form-group"><label for="' + this.id + '_zoom">ズームレベル</label><input type="text" name="' + this.id + '_zoom" id="' + this.id + '_zoom" mt:watch-change="1" class="zoom form-control" /></div>',
                '</div>',
            ];
            const $field = $(fieldHTML.join(''));
            const $map = $('<div id="map_' + this.id + '" style="width: 400px; height: 300px;"></div>');
            const $mapArea = $('<div class="form-group"></div>');
            $mapArea.append($map);

            const $searchByAddress = $('<div class="form-group"><label for="' + this.id + '_address">ジオコーディング</label><input type="text" name="' + this.id + '_address" id="' + this.id + '_address" class="address form-control w-50 mb-2" /><input type="button" id="'+ this.id + '_address_search" value="住所から検索" /></div>');

            if (this.data.value) {
                json = JSON.parse(this.data.value);
                $field.find("#" + this.id + " input.lat").val(parseFloat(json.lat));
                $field.find("#" + this.id + " input.lng").val(parseFloat(json.lng));
                $field.find("#" + this.id + " input.zoom").val(parseInt(json.zoom));
            }

            $field.find('.col').append($mapArea);
            $field.find('.col').append($searchByAddress);
            self.$edit_field.append($field);

            self._mapInit($map, json, 1);
            $(document).on('click', '#' + this.id + '_address_search', $.proxy(self._geocoder, self));

            return self.$edit_field;
        },
        save: function () {
            const lat = this.$edit_field.find('input.lat').val();
            const lng = this.$edit_field.find('input.lng').val();
            const zoom = this.$edit_field.find('input.zoom').val();
            const json = {
                'lat': lat,
                'lng': lng,
                'zoom': zoom
            };
            const $map = this.view_field.find('#map_' + this.id);
            this.data.value = JSON.stringify(json);
            this._mapInit($map, json);
        },
        set_option: function (name, val) {
            const style_name = name.replace('field_option_', '');
            this.options[style_name] = val;
        },
        get_data: function () {
            return {
                'value': this.data.value,
                'html': this.get_html(this.data.value),
                'options': this.options,
            }
        },
        get_html: function (json) {
            return "<mt-googlemap>" + JSON.stringify(json) + "</mt-googlemap>";
        }
    });

    MT.BlockEditorFieldManager.register('googlemap', BEF.GoogleMap);

})(jQuery);

フロント側の表示処理

<mt-googlemap>に緯度・経度・ズームレベルが入っているので、「Google Maps JavaScript API」や「Google Static Maps API」を使って表示処理をすれば良いと考えられます。モディファイアで加工する方法もあるでしょう。

まとめ

このようにJavaScriptの知識でブロックエディタに独自フィールドが追加できました。アセットが必要なフィールドはimage.jsを参照すれば良さそうです。プラグイン化できればブロックエディタフィールドのエコシステムができるのかな、などと考えています。

2017/3/3 8:30追記

プラグイン化に成功しましたので、以下のリポジトリよりダウンロードしてお試しいただけます。事前にAPIキーの取得が必要です。詳しくはREADME.mdをご覧ください。

2017/11/7 20:00追記

画面キャプチャは変更していませんが、Movable Type 7正式版でも動作するようにコードを修正いたしました。