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に「画像+テキスト」のフィールドを追加する」を公開いたしました。
完成した画面は以下の通りです。
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>
要素を付加しないデータを格納しました。lat
やlng
等のキーで格納しても良いのですが、今回はvalue
にまとめて入れておきました。
完成した画面とスクリプト
完成した画面とスクリプト(googlemap.js)を示します。複数の地図の表示にも対応できています。
; (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正式版でも動作するようにコードを修正いたしました。