「Wordpressの」な話題でもあるけれど、どっちかといえば jquery なネタ。
欲しいのはビジュアルエディタのカスタマイズではなくHTMLエディタのカスタマイズ、と言った通りで、これを作りたかった:
で、これを作るのにあちこち徘徊して、個人的に面白い発見が多くて、ちょいと時間はかかった(正味丸二日)もんでストレスもあったけど、結構楽しかった。ちょっとこれについて書いておこうと思う。
まず割と最初の方で Make Shortcodes User-Friendly – Solis Lab Solution が見つかった。なお、これを見つけたときにはまだこれがHTMLエディタのカスタマイズにはならないことには気付いていなかった。このサイトを訪れてもテクニカルな内容全体が書かれているのではないのだけれど、ちょっとわかりにくいがソースコードのダウンロードのリンクが本文中に埋め込まれてるので、入手してみた。
Make Shortcodes User-Friendly – Solis Lab Solutionの php 部分は、「Wordpress投稿エディタにカスタムボタンを追加する」トピックでググればかなり大量にみつかるどの情報とも大差なく、特に何の変哲もないもの:
1 <?php
2 /*
3 Plugin Name: mygallery
4 Plugin URI: http://wphardcore.com
5 Description: A simple user interface for Gallery shortcode
6 Version: 0.1
7 Author: Gary Cao
8 Author URI: http://garyc40.com
9 */
10
11 if ( ! defined( 'ABSPATH' ) )
12 die( "Can't load this file directly" );
13
14 class MyGallery
15 {
16 function __construct() {
17 add_action( 'admin_init', array( $this, 'action_admin_init' ) );
18 }
19
20 function action_admin_init() {
21 // only hook up these filters if we're in the admin panel, and the current user has permission
22 // to edit posts and pages
23 if ( current_user_can( 'edit_posts' ) && current_user_can( 'edit_pages' ) ) {
24 add_filter( 'mce_buttons', array( $this, 'filter_mce_button' ) );
25 add_filter( 'mce_external_plugins', array( $this, 'filter_mce_plugin' ) );
26 }
27 }
28
29 function filter_mce_button( $buttons ) {
30 // add a separation before our button, here our button's id is "mygallery_button"
31 array_push( $buttons, '|', 'mygallery_button' );
32 return $buttons;
33 }
34
35 function filter_mce_plugin( $plugins ) {
36 // this plugin file will work the magic of our button
37 $plugins['mygallery'] = plugin_dir_url( __FILE__ ) . 'mygallery_plugin.js';
38 return $plugins;
39 }
40 }
41
42 $mygallery = new MyGallery();
classになってたりなってなかったりと、人それぞれ微妙には違うけど、このサイトに至る前には[WP]TinyMCEのビジュアルリッチエディタにカスタムボタンを追加するとかその他もろもろみていたので、ここでの発見は特になかった。
ソースコードで初見で、を?、と思ったのは javascript の方。まずこんな書き方、考えもしなかった:
1 // closure to avoid namespace collision
2 (function() {
3 // ...
4 })()
jquery や、jquery でなくても javascript に慣れている人ならなんとも思わないんだろうけど、これまで javascript の名前付けでヒヤヒヤし続けていたので、これはなるほど、と思った。(このサイトに辿り着く前にこの記述はみつけていたんだけど、この親切なコメントが入ってたのはこのサイトでようやく。)動的な言語なんだから出来てある意味当然ではあるけれど。
で、さらに先にいって、
1 // closure to avoid namespace collision
2 (function() {
3 // creates the plugin
4 tinymce.create('tinymce.plugins.mygallery', {
5 // ...省略
6 });
7
8 // registers the plugin. DON'T MISS THIS STEP!!!
9 tinymce.PluginManager.add('mygallery', tinymce.plugins.mygallery);
10
11 // executes this when the DOM is ready
12 jQuery(function() {
この DOM is ready の中でまたほとんど同じような「を?」が:
1 //続き…
2 // creates a form to be displayed everytime the button is clicked
3 // you should achieve this using AJAX instead of direct html code like this
4 var form = jQuery('\
5 <div id="mygallery-form">\
6 <table id="mygallery-table" class="form-table">\
7 <tr>\
8 <th><label for="mygallery-columns">Columns</label></th>\
9 <td><input type="text" id="mygallery-columns" name="columns" value="3" /><br />\
10 <small>specify the number of columns.</small></td>\
11 </tr>\
12 <!--省略-->\
13 </table>\
14 <p class="submit">\
15 <input type="button" id="mygallery-submit" class="button-primary" value="Insert Gallery" name="submit" />\
16 </p>\
17 </div>');
18
19 var table = form.find('table');
20 form.appendTo('body').hide();
あぁなるほど…。(このjsが読み込まれたら)form をダナミックに作って、こっそり body に追加してすぐさま隠す、と。
で、2つ前のコード引用で「省略」した部分はこんなの:
1 // creates the plugin
2 tinymce.create('tinymce.plugins.mygallery', {
3 // creates control instances based on the control's id.
4 // our button's id is "mygallery_button"
5 createControl : function(id, controlManager) {
6 if (id == 'mygallery_button') {
7 // creates the button
8 var button = controlManager.createButton('mygallery_button', {
9 title : 'MyGallery Shortcode', // title of the button
10 image : '../wp-includes/images/smilies/icon_mrgreen.gif', // path to the button's image
11 onclick : function() {
12 // triggers the thickbox
13 var width = jQuery(window).width(), H = jQuery(window).height(), W = ( 720 < width ) ? 720 : width;
14 W = W - 80;
15 H = H - 84;
16 tb_show( 'My Gallery Shortcode', '#TB_inline?width=' + W + '&height=' + H + '&inlineId=mygallery-form' );
17 }
18 });
19 return button;
20 }
21 return null;
22 }
23 });
どうやらこの tb_show なるもので、mygallary-formをアクティベイトするらしいぞ、と。
thickboxについてはこれまで知らずにいたけれど、まず WordPress では標準に含まれていて、Wordpress でなくても既に気付かずにワタシはお世話になってたようだ。thickboxとの格闘についてはまた後ほど触れるとして、ワタシにとっての当座のゴールであるところの、最初にあげた画像のような UI のための、一番完成イメージに近いものであるはずだ、と思いながら、このコードから一旦離れた。
そのワケとはもちろん…欲しいのはビジュアルエディタのカスタマイズではなくHTMLエディタのカスタマイズに関係して。いや、それよりも前に…。
Make Shortcodes User-Friendly – Solis Lab Solutionよりももっと単純な、Guide to Creating Your Own WordPress Editor Buttonsをね、まずはお試して自分のに組み込もうとして、なぜだか苦労して。単独では問題ないのに、名前やらを変えつつ、自分のに取り込むとうまくいかない。原因は単純ミスだったので、ここでお披露目する内容ではないんであれですが、Guide to Creating Your Own WordPress Editor Buttonsはわかりやすいと思う。ともあれ、これと格闘してました、最初。
で、Guide to Creating Your Own WordPress Editor Buttonsの組み込みが出来て初めてわかったわけよ。HTMLエディタのカスタマイズは TinyMCE ではなく quicktagsということに。あらら、目標のMake Shortcodes User-Friendly – Solis Lab Solutionがちょっと遠ざかってしまった…。
ただ、quicktags のカスタマイズについては、「quicktagsを使うのかぁ」とわかってしまえば見つけるのは簡単で、いつも通り StackOverflowに答えをみつけた:
1 <?PHP
2 function _add_my_quicktags(){ ?>
3 <script type="text/javascript">
4 QTags.addButton( 'alert', 'Alert Button', simple_alert );
5
6 function simple_alert() {
7 alert('hello, example callback function');
8 }
9 </script>
10 <?php
11 }
12 add_action('admin_print_footer_scripts', '_add_my_quicktags');
13 ?>
UPDATE…. Adding a Callback function that utilizes the passed in variables…
1 // Add a button called 'abbr' with a callback function
2 QTags.addButton( 'abbr', 'Abbr', abbr_prompt );
3 // and this is the callback function
4 function abbr_prompt(e, c, ed) {
5 var prmt, t = this;
6 if ( ed.canvas.selectionStart !== ed.canvas.selectionEnd ) {
7 // if we have a selection in the editor define out tagStart and tagEnd to wrap around the text
8 // prompt the user for the abbreviation and return gracefully on a null input
9 prmt = prompt('Enter Abbreviation');
10 if ( prmt === null ) return;
11 t.tagStart = '<abbr title="' + prmt + '">';
12 t.tagEnd = '</abbr>';
13 } else if ( ed.openTags ) {
14 // if we have an open tag, see if it's ours
15 var ret = false, i = 0, t = this;
16 while ( i < ed.openTags.length ) {
17 ret = ed.openTags[i] == t.id ? i : false;
18 i ++;
19 }
20 if ( ret === false ) {
21 // if the open tags don't include 'abbr' prompt for input
22 prmt = prompt('Enter Abbreviation');
23 if ( prmt === null ) return;
24 t.tagStart = '<abbr title="' + prmt + '">';
25 t.tagEnd = false;
26 if ( ! ed.openTags ) {
27 ed.openTags = [];
28 }
29 ed.openTags.push(t.id);
30 e.value = '/' + e.value;
31 } else {
32 // otherwise close the 'abbr' tag
33 ed.openTags.splice(ret, 1);
34 t.tagStart = '</abbr>';
35 e.value = t.display;
36 }
37 } else {
38 // last resort, no selection and no open tags
39 // so prompt for input and just open the tag
40 prmt = prompt('Enter Abbreviation');
41 if ( prmt === null ) return;
42 t.tagStart = '<abbr title="' + prmt + '">';
43 t.tagEnd = false;
44 if ( ! ed.openTags ) {
45 ed.openTags = [];
46 }
47 ed.openTags.push(t.id);
48 e.value = '/' + e.value;
49 }
50 // now we've defined all the tagStart, tagEnd and openTags we process it all to the active window
51 QTags.TagButton.prototype.callback.call(t, e, c, ed);
52 };
「prompt」ではワタシの目的のものにはなりえないけれど、まずQuicktags APIでは「Here is a dump of the docblock from quicktags.js, it’s pretty useful on it’s own.」という風に、やたら気付きにくい説明の、「QTags.addButtonにコールバックを渡す使い方」の「完全なサンプルになっていそうだ」てこと、つまりボタンが押された場合の状況に応じてどのように振舞うべきか、どうやら網羅できていそうだ、と思い、つまり、ワタシの目標はこの時点で、これと「Make Shortcodes User-Friendly – Solis Lab Solution」をミックスすれば完成するだろう、となった。
そういうわけで、StackOverflowとMake Shortcodes User-Friendly – Solis Lab Solutionの合わせ業にトライし始めて…。
困ったのは thickbox の仕様がえらく調べにくい、てことだった。嘘だと思うならthickboxに実際にいってドキュメントをみてみればいい。肝心なことはあんまりちゃんと書かれてない。
「tb_show()」を「prompt」呼び出しからそのまま差し替えてもうまくいかない。「tb_show()」は「モーダル」起動出来ることになっているけれど、この言い方が誤解を招くと、思った。昔ながらのエンジニアはきっと、モーダルで起動したのなら、「何かユーザがアクションを起こしてダイアログを閉じるまで、処理が戻ってこない」と思うであろうが、これはそうではない。tb_show() は(modal=trueでも)、プログラムは tb_show() 呼び出しに続く処理を、エンドユーザのアクションに関係なく続行してしまう。thickbox における「モーダル」は、単にエンドユーザ目線、なのな。「このダイアログにだけ集中してね」てことな。
だとしてだ。次に思うのはtb_show()にコールバック登録出来たりしねー?と思うんだな。でもこれはハズレ。tb_show は表示非表示のコントロールだけが責務、なのね。
あぁ、そうか。結局 submit ボタンにイベントハンドラを渡す、ごく普通のことをするだけか、と、気づいてしまえば当たり前のことに、結構な時間を費やしてからようやく気付いた。というわけで、だいたいこんな感じで完成:
1 <?php
2 /*
3 * for writers functionality
4 */
5 if (!function_exists('_add_my_quicktags')) {
6 function _add_my_quicktags() {
7 // ...省略...
8 ?>
9 <script type="text/javascript">
10 /*
11 * Add function callback button
12 * Params are the same as above except the 'Opening Tag' param
13 * becomes the callback function's name
14 * and the 'Closing Tag' is ignored.
15 */
16 QTags.addButton('pygmentize', 'Pygmentize', prompt_user);
17
18 var _wppygsh_generated_tb_submit_callback = function(opentag) {};
19
20 function _wppygsh_show_tb(cb, prompt_user_this, e, c, ed) {
21 _wppygsh_generated_tb_submit_callback = function (opentag) {
22 cb(opentag);
23 // now we've defined all the tagStart, tagEnd and openTags
24 // we process it all to the active window
25 QTags.TagButton.prototype.callback.call(prompt_user_this, e, c, ed);
26 }
27 var width = jQuery(window).width(), H = jQuery(window).height(), W = (720 < width) ? 720 : width;
28 tb_show('Insert Pygmentize Shortcode', '#TB_inline?modal=true&inlineId=wppygsh-form&width=' + W);
29 }
30 function prompt_user(e, c, ed) {
31 var prompt_user_this = this;
32 if (ed.canvas.selectionStart !== ed.canvas.selectionEnd) {
33 // we have a selection in the editor
34 _wppygsh_show_tb(function(opentag) {
35 prompt_user_this.tagStart = opentag;
36 prompt_user_this.tagEnd = '[/<?php echo $shortcode_name ?>]';
37 }, prompt_user_this, e, c, ed);
38 } else if (ed.openTags) {
39 // we have an open tag
40 var ret = false, i = 0;
41 while (i < ed.openTags.length) { // find if it's ours?
42 ret = ed.openTags[i] == prompt_user_this.id ? i : false;
43 i++;
44 }
45 if (ret === false) {
46 // the open tags don't include 'pygmentize'
47 _wppygsh_show_tb(function(opentag) {
48 prompt_user_this.tagStart = opentag;
49 prompt_user_this.tagEnd = false;
50 if (!ed.openTags) {
51 ed.openTags = [];
52 }
53 ed.openTags.push(prompt_user_this.id);
54 e.value = '/' + e.value;
55 }, prompt_user_this, e, c, ed);
56 } else {
57 // otherwise close the 'pygmentize' tag
58 ed.openTags.splice(ret, 1);
59 prompt_user_this.tagStart = '[/<?php echo $shortcode_name ?>]';
60 e.value = prompt_user_this.display;
61 // now we've defined all the tagStart, tagEnd and openTags
62 // we process it all to the active window
63 QTags.TagButton.prototype.callback.call(prompt_user_this, e, c, ed);
64 }
65 } else {
66 // last resort, no selection and no open tags
67 // so prompt for input and just open the tag
68 _wppygsh_show_tb(function(opentag) {
69 prompt_user_this.tagStart = opentag;
70 prompt_user_this.tagEnd = false;
71 if (!ed.openTags) {
72 ed.openTags = [];
73 }
74 ed.openTags.push(prompt_user_this.id);
75 e.value = '/' + e.value;
76 }, prompt_user_this, e, c, ed);
77 }
78 }
79 // executes this when the DOM is ready
80 jQuery(function($) {
81 // creates a form to be displayed everytime the button is clicked
82 // you should achieve this using AJAX instead of direct html code like this
83 var form = jQuery('<div id="wppygsh-form">\
84 <!--...-->\
85 <p class="submit">\
86 <input type="button" class="button-primary wppygsh-submit" value="Insert Shortcode" name="submit" />\
87 <input type="button" class="button-primary wppygsh-cancel" value="Cancel" name="cancel" />\
88 </p>\
89 <!-- class="form-table" -->\
90 <table id="wppygsh-table">\
91 <!--...-->\
92 <tr>\
93 <th><label for="wppygsh-lang">lang</label></th>\
94 <td><input type="text" id="wppygsh-lang" name="lang" value="" style="width: 180px;" />\
95 ←<select id="wppygsh-lang-pulldown"><?php echo $lexers ?></select>\
96 <span class="wppygsh-description">specify the lang, for example ``pycon``.</span></td>\
97 </tr>\
98 <!--...省略...-->\
99 </table>\
100 <!--...-->\
101 <p class="submit">\
102 <input type="button" class="button-primary wppygsh-submit" value="Insert Shortcode" name="submit" />\
103 <input type="button" class="button-primary wppygsh-cancel" value="Cancel" name="cancel" />\
104 </p>\
105 </div>');
106 var table = form.find('table');
107 table.find("tr").css("vertical-align", "top");
108 form.appendTo('body').hide();
109
110 var lang_input = form.find("#wppygsh-lang");
111 form.find("#wppygsh-lang-pulldown").on("change", function () {
112 var e = $(this);
113 lang_input.val(e.val());
114 });
115
116 // handles the click event of the submit button
117 form.find('.wppygsh-submit').click(function() {
118 // defines the options and their default values
119 // again, this is not the most elegant way to do this
120 // but well, this gets the job done nonetheless
121 var options = {
122 // ...省略...
123 };
124 var shortcode = '[<?php echo $shortcode_name ?>';
125
126 for (var index in options) {
127 var ctrl = table.find('#wppygsh-' + index);
128 // ...省略...
129 var ctrltype = ctrl.attr('type');
130 // ...省略...
131 var value = ctrl.val();
132 // ...省略...
133 // attaches the attribute to the shortcode only if it's different from the default value
134 if (value !== options[index]) {
135 shortcode += ' ' + index + '="' + value + '"';
136 }
137 }
138
139 shortcode += ']';
140
141 // set the shortcode into global callback `_wppygsh_generated_tb_submit_callback'
142 _wppygsh_generated_tb_submit_callback(shortcode);
143
144 // closes Thickbox
145 tb_remove();
146 });
147 // handles the click event of the cancel button
148 form.find('.wppygsh-cancel').click(function() {
149 // closes Thickbox
150 tb_remove();
151 });
152 // add functionality to toggle descriptions.
153 var desc = form.find('.wppygsh-description');
154 desc.each(function (index) {
155 // ...省略...
156 });
157 });
158 </script>
159
160 <?php
161 }
162 // We can attach it to 'admin_print_footer_scripts' (for admin-only)
163 // or 'wp_footer' (for front-end only)
164 add_action('admin_print_footer_scripts', '_add_my_quicktags');
165 }
166 ?>
コールバックのチェインになっているのが読みにくいポイントかもしれないけど、じっくり読めば難しいことはやってないです。
なお、「簡単な一発タグ挿入ボタンが欲しい!」程度の軽いニーズであれば、Quicktags APIを読むだけで簡単に作れます、きっと。(あ、こっちの方がわかりやすいか。)