WordPressの投稿エディタにカスタムボタン(HTML編集モードで)

「Wordpressの」な話題でもあるけれど、どっちかといえば jquery なネタ。

欲しいのはビジュアルエディタのカスタマイズではなくHTMLエディタのカスタマイズ、と言った通りで、これを作りたかった:
add_custom_qtags_mine

で、これを作るのにあちこち徘徊して、個人的に面白い発見が多くて、ちょいと時間はかかった(正味丸二日)もんでストレスもあったけど、結構楽しかった。ちょっとこれについて書いておこうと思う。

まず割と最初の方で Make Shortcodes User-Friendly – Solis Lab Solution が見つかった。なお、これを見つけたときにはまだこれがHTMLエディタのカスタマイズにはならないことには気付いていなかった。このサイトを訪れてもテクニカルな内容全体が書かれているのではないのだけれど、ちょっとわかりにくいがソースコードのダウンロードのリンクが本文中に埋め込まれてるので、入手してみた。

Make Shortcodes User-Friendly – Solis Lab Solutionの php 部分は、「Wordpress投稿エディタにカスタムボタンを追加する」トピックでググればかなり大量にみつかるどの情報とも大差なく、特に何の変哲もないもの:

mygallary.php
 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 の方。まずこんな書き方、考えもしなかった:

mygallary.js
1 // closure to avoid namespace collision
2 (function() {
3   // ...
4 })()

jquery や、jquery でなくても javascript に慣れている人ならなんとも思わないんだろうけど、これまで javascript の名前付けでヒヤヒヤし続けていたので、これはなるほど、と思った。(このサイトに辿り着く前にこの記述はみつけていたんだけど、この親切なコメントが入ってたのはこのサイトでようやく。)動的な言語なんだから出来てある意味当然ではあるけれど。

で、さらに先にいって、

mygallary.js
 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 の中でまたほとんど同じような「を?」が:

mygallary.js
 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つ前のコード引用で「省略」した部分はこんなの:

mygallary.js
 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に答えをみつけた:

Simple example that will add a Quicktag button that calls a Javascript function when it is clicked…

 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」をミックスすれば完成するだろう、となった。

そういうわけで、StackOverflowMake 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 &nbsp;<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 &larr;<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 &nbsp;<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を読むだけで簡単に作れます、きっと。(あ、こっちの方がわかりやすいか。)