Greasemonkeyによるアプリケーション開発

第2回Greasemonkeyでカレンダアプリを作ってみる:まずはシンプルに。

前回はGreasemonkeyの基本的な使い方について解説しました。今回からは本題の通りGreasemonkeyによるアプリケーション開発の話を進めていきたいと思います。

どのページを開いていても、ちらっとカレンダを見ることができたら便利ですよね?(⁠⁠ちょっと首傾けるだけで本物の紙のカレンダを見ることができてすでに便利だ、これ以上カレンダなんていらないよ」という方、今すぐそのカレンダもしくはあたなの席を移すか、そのカレンダを捨てさってください)⁠←これは冗談ですよ。念のため⁠⁠ 少なくとも私は便利に思いました。そこでページ内にカレンダを表示する機能をGreasemonkeyによるアプリケーションとして実現することとします。具体的には図1のようなカレンダをページ内に表示できるようにします。

図1 できあがりのカレンダ
図1 できあがりのカレンダ

カレンダを命令一発で表示できるとよいのですが、そんな魔法はありません(魔法のようなカレンダライブラリは世の中にすでにあると思いますが、ここではそのようなライブラリに頼るのはやめます⁠⁠。少なくとも私は知りません。javascriptに標準で用意されているDateオブジェクトとDOM操作を駆使して作成することにします。

まずはカレンダを表現するHTMLをそのままページに挿入してみる

まずは単純にその月のカレンダを表示することから取りかかります。HTMLのTABLE(とTR、TH、TD)タグを利用してカレンダを表示することとします。できあがりのHTMLは図2のようになることを想定しています。

図2 2007年8月のカレンダを表すHTML
<table>
  <tr>
    <th colspan="7">2007/8</td>
  </tr>
  <tr>
    <th>S</th><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th>
  </tr>
  <tr>
    <td>29</td><td>30</td><td>31</td><td>1</td><td>2</td><td>3</td><td>4</td>
  </tr>
  <tr>
    <td>5</td><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td>
  </tr>
  <tr>
    <td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td><td>18</td>
  </tr>
  <tr>
    <td>19</td><td>20</td><td>21</td><td>22</td><td>23</td><td>24</td><td>25</td>
  </tr>
  <tr>
    <td>26</td><td>27</td><td>28</td><td>29</td><td>30</td><td>31</td><td>1</td>
  </tr>
</table>

この形式のHTMLを実行時点の月に合わせて作成すればよいわけですが、まずは試しに固定的に2007年8月の月間カレンダを表すHTMLを用意して、それをページ内に出力するユーザスクリプトを作成してみます。

今回最初のユーザスクリプトは図3です。

図3 2007年8月のカレンダをページ上に表示するユーザスクリプト(ユーザスクリプトはこちら
// ==UserScript==
// @name         mini_calendar1
// @namespace    http://gomaxfire.dnsdojo.com/
// @description  the 1st mini calendar
// @include      *
// ==/UserScript==

var table =  // (1)
<table border="1">
  <tr>
    <th colspan="7">2007/8</td>
  </tr>
  <tr>
    <th>S</th><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th>
  </tr>
  <tr>
    <td>29</td><td>30</td><td>31</td><td>1</td><td>2</td><td>3</td><td>4</td>
  </tr>
  <tr>
    <td>5</td><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td>
  </tr>
  <tr>
    <td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td><td>18</td>
  </tr>
  <tr>
    <td>19</td><td>20</td><td>21</td><td>22</td><td>23</td><td>24</td><td>25</td>
  </tr>
  <tr>
    <td>26</td><td>27</td><td>28</td><td>29</td><td>30</td><td>31</td><td>1</td>
  </tr>
  </table>;   

var div = document.createElement("div");  // (2)
div.innerHTML = table.toSource();         // (3)
document.body.insertBefore(div, document.body.firstChild); // (4)

(1)でカレンダを表すHTMLをE4Xを使って変数tableにセットしています。E4XはECMAScriptにXMLを表現する文法とセマンティクスを追加する拡張仕様です。仕様について詳しくはECMAのページに載っているドキュメントに示されていますが、ここではXMLをインラインで記述することでxml型の値を表すことができ、xml型の値はtoSource関数を呼び出すことでそのxmlの文字列表現を取得することができる、ということだけ分かればこのユーザスクリプトの処理は理解できると思います。

(2)でdiv要素を作成し、(3)でその内側のhtmlを(1)で作成したカレンダを示すhtmlに設定しています。最後に(4)でページのbody要素の先頭に作成したdiv要素を追加しています。

このユーザスクリプトを実行すると、図4のように質素なカレンダが表示されます。

図4 図3のユーザスクリプトの実行結果
図4 図3のユーザスクリプトの実行結果

最初の単純なユーザスクリプトとしてはできは上々だと思いますが、まだまだもの足りません。以下のような不満があると思います。

  • (1) 常に2007年8月に固定のカレンダである。実行時の月のカレンダにしたい。
  • (2) ページの一番上に表示されてしまい、スクロールすると見えなくなってしまう・見栄えがよくない。
  • (3) 常にカレンダが表示されているのは邪魔である。必要なときだけ表示するようにしたい。

上から順に不満を解決していきたいと思います。

実行時の月のカレンダにする

実行時の月のカレンダにするには、実行時の月を取得する必要があります。これには組み込みオブジェクトのDateを使います。new Date()を実行することでその時点の日時情報を持つDateオブジェクトが返ります。Dateオブジェクトは内部で保持している日時情報を取得、設定するメソッドを持っています。そこで、このメソッド群を活用して、実行時の月を取得し、日にちを一日ずらしながらカレンダを表すHTMLを生成する、というアプローチをとることとします。

前ページでで示したスクリプト図3ではHTML文字列をインラインで用意してそれをそのままdiv要素内のHTMLとして設定することでページ内にカレンダを挿入していました。このようにHTMLを生成するのにできあがりのHTMLの文字列を生成していく方法をとることもできますが、DOMオブジェクトを生成してツリーを構成していく方法もあります。ここでは後者のDOMツリーを構成していく方法をとることとします。

一般に、DOMツリーを構成する場合、document.createElement()やElement#appendChild()(Greasemonkeyでは正確にはElementそものではなくXPCNativeWrapperでラップされたオブジェクト)を利用することになりますが、そのまま使うとプログラムが長くなって読みにくくなったり、階層構造が分かりにくくなったりしがちです。そこで私はこの手のプログラムを書くときは短くプログラムが書けるようにするためのユーティリティ関数を自作して対処しています。図5が私が自作したユーティリティ関数の定義です。

図5 ユーティリティ関数の定義
/**
 * document.createElement() +アルファな関数
 * attrsに指定した属性を設定し、stylesに指定したCSSプロパティを設定する
 */
function $tag(tagName, attrs, styles){
  var tag = document.createElement(tagName);
  if(attrs){
    for(a in attrs){
      if(attrs.hasOwnProperty(a)){
        tag[a] = attrs[a];
      }
    }
  }
  if(styles){
    for(a in styles){
      if(styles.hasOwnProperty(a)){
        tag.style[a] = styles[a];
      }
    }
  }
  return tag;
}

  
/**
 * $tagのショートカット定義
 * $div()でdiv要素を生成できるようにする。
 * 引数は$tagの第2引数、第3引数をそのまま第1引数、第2引数として利用できる。
 */
"div p span a img table tr th td form label input textarea".split(" ").forEach(function(tagName){
    var func = function(attrs, styles){
      return $tag(tagName, attrs, styles);
    };
    eval("$" + tagName + "= func;" );
  });


/**
 * document.createTextNode()のエイリアス
 */
function $text(text){
  return document.createTextNode(text);
}

/**
 * Element#appendChild() +アルファな関数
 * 第1引数の要素の末尾要素として第2引数以降で指定する要素を追加する
 * Element.prototypeの関数として定義するほうがスマートになりそうだが、
 * GreasemonkeyではElement要素を直接扱えないのでこの定義方法をとった。
 */
function $add(parent, children){
  if(arguments.length < 2) return "";
  for(var i=1, child; child=arguments[i];i++){
    if(typeof child != "object"){
      child = $text(child+"");
    }
    parent.appendChild(child);
  } 
  return parent;
}

これらのユーティリティ関数を使ったところで実行速度が上がる訳ではありません。むしろ多少下がるでしょう。しかし込み入ったツリーを作るプログラムでは見た目上分かりやすくなる方が多少の速度低下よりもスクリプトのメンテナンス性が高くなると考え、このような関数を使うことにしています。

使い方の具体例を図6に示します。

図6 ユーティリティ関数の利用例
表現したいHTML:
<form action="http://hoge.foo.com">
  <label>label<input type="checkbox" /></label>
  <input type="submit" value="send" />
</form>
document.appencChild/document.craeteElementを使った場合:
var form = document.createElement("form");
form.action = "http://hoge.foo.com";
var label = document.createElement("label");
label.textContent = "label";
var checkbox = document.craeteElement("input");
checkbox.type = "checkbox";
var submit = document.createElement("input");
submit.type = "submit";
submit.value = "send";
form.appendChild(label);
label.appendChild(checkbox);
form.appendChild(submit);
document.body.appendChild(form);
$add, $tagの派生関数群を使った場合
$add(document.body,
     $add($form({action:"http://hoge.foo.com"}),
          $add($label(), 
               "label",
               $input({type:"checkbox"})),
          $input({type:"submit", value:"send"})));

いかがでしょう。階層構造も分かりやすいし、各タグ要素の属性値も読みやすいと思います。階層構造が分かりやすいのはインデントのおかげですが、インデントをサポートする機能のあるエディタであれば、おおむね上記のようなインデントになるでしょう。前者のコードも文法を無視してインデントすれば階層構造の理解に役立てることはできるでしょうが、そのようなインデントを自らつけるのは大変だと思います。

さて前置きが長くなってしまいましたが、肝心の実行時の月のカレンダHTMLを作成するスクリプトを図7に示しましょう。

図7 実行時の月のカレンダをページ上に表示するユーザスクリプト(ユーザスクリプトはこちら
// ==UserScript==
// @name         mini_calendar2
// @namespace    http://gomaxfire.dnsdojo.com/
// @description  the 2nd mini calendar
// @include      *
// ==/UserScript==

//  図5のユーティリティ関数群をここで定義するが省略

/** 実行時の月のカレンダを表すtable要素を生成する */
function makeCalendarTable(){
  var table = $table({border:1}); 
  setMonthHeader();
  setDayHeader();
  setDates();
  return table;
  
  function setMonthHeader(){
    var d = new Date();
    var year = d.getFullYear();
    var month = d.getMonth() + 1;
    $add(table, 
         $add($tr(), 
              $add($td({colSpan:7}), year + "/" + month))); 
  }
  
  function setDayHeader(){
    var days = "S M T W T F S".split(" ");
    var tr = $tr();
    days.forEach(function(day){
        $add(tr, 
             $add($th(), day));
      });
    $add(table, tr);
  }
  
  function setDates(){
    var d = new Date();
    var nextMonth = d.getMonth() < 11 ? d.getMonth() + 1 : 0;
    d.setDate(1);                               // (3)-1
    d.setDate(-d.getDay() + 1);                 // (3)-2
    
    var finish = false;
    for(;;){
      var tr = $tr();
      for(var i=0;i<7;i++){                     // (3)-3
        $add(tr, $add($td(), d.getDate()));
        d.setDate(d.getDate() + 1);             // (3)-4
        if(d.getMonth() == nextMonth) finish = true; // (3)-5
      }
      $add(table, tr);
      if(finish)break;                          // (3)-6
    }
  }
}

document.body.insertBefore($add($div(), makeCalendarTable()), 
                           document.body.firstChild);

図7のmakeCalendarTable関数で実行時の月のカレンダを表すtableタグをルートとしてDOMツリーを生成しいます。内部の処理は三つの関数内関数定義に分離して読みやすいようにしました。

(1)setMonthHeader
"年/月"の形式で月を表すヘッダを生成する関数
(2)setDayHeader
曜日を表すヘッダを生成する関数
(3)setDates
カレンダの本体である日にちを表すセル要素を生成する関数

(1)⁠2)の関数は行数も少ないですし、解説の必要はないでしょう。⁠3)の関数は(1)⁠2)と比べると多少行数も多いので簡単に処理手順を解説します。

まず、現在の日時を取得して、1日に設定にします(⁠⁠3⁠⁠-1⁠⁠。そして、曜日に合わせて前の月の日曜日にずらしてから(⁠⁠3⁠⁠-2⁠⁠、一日ずつずらしてセルを生成する処理を週単位で繰り返し(⁠⁠3⁠⁠-3,4⁠⁠、次の月に入る週まで生成したら終了します(⁠⁠3⁠⁠-5,6⁠⁠。

最後の行でdiv要素にmakeCalendarTable()で生成したカレンダのtableを追加し、それをbody要素の先頭に追加することで、カレンダを表示しています。これで最初の不満は解消できました。

見栄えを調整する

第2の不満は

「ページの一番上に表示されてしまい、スクロールすると見えなくなってしまう・見栄えがよくない。」

でした。これはCSSを設定することで解決できます。GreasemonkeyではCSSを追加設定するためのGM_addStyle関数が用意されていますので、これを利用することにします。

図7のユーザスクリプトのmakeCalendarTable関数の処理に、タグ要素にid属性やclass属性の値を設定する処理を追加して、CSSを設定しやすいようにします。そして、GM_addStyle関数でCSSを設定します。その結果が図8のユーザスクリプトです。

図8 CSSを使ってカレンダの見栄えを向上させたユーザスクリプト(ユーザスクリプトはこちら
// ==UserScript==
// @name         mini_calendar3
// @namespace    http://gomaxfire.dnsdojo.com/
// @description  the 3rd mini calendar
// @include      *
// ==/UserScript==

//  図5のユーティリティ関数群をここで定義するが省略

function makeCalendarTable(){
  // 関数内で共通に利用する変数を定義 -(1)	 
  var CSS_PREFIX = "_gcal_";
  var DAYS = "Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" ");
  var TODAY = new Date();
  var TODAY_DATE = TODAY.getDate();
  var TODAY_MONTH = TODAY.getMonth();
  
  var table = $table({id:css("")}); 
  setMonthHeader();
  setDayHeader();
  setDates();
  setStyle();
  return table;
  
  /**
   *  表示中のページ内のCSSで使われているクラス名との衝突をさけるため、
   *  適当にプレフィックスをつけることにする。そのための補助関数。 -(1)
   */
  function css(name){
    return CSS_PREFIX + name;
  }

  function setMonthHeader(){
    var d = new Date();
    var year = d.getFullYear();
    var month = d.getMonth() + 1;

    // -(1)
    $add(table, 
         $add($tr({className:css("header")}),  
              $add($th({className:css("header"), colSpan:7}), year + "/" + month))); 
  }
  
  function setDayHeader(){
    var tr = $tr({className:css("dayNames")});
    // 曜日名を示す配列は日にちを示すtd要素のclass名にも
    // 流用するために外側で定義した -(1)
    DAYS.forEach(function(day){
        $add(tr, 
             $add($th({className:[css(day),css("dayName")].join(" ")}), 
                  day.substring(0,1)));
      });
    $add(table, tr);
  }
  
  function setDates(){
    var d = new Date();
    var nextMonth = d.getMonth() < 11 ? d.getMonth() + 1 : 0;
    d.setDate(1);
    d.setDate(-d.getDay() + 1);
    
    var finish = false;
    for(;;){
      var tr = $tr();
      for(var i=0;i<7;i++){
        var td = $add($td(), d.getDate());
        setClassName(td, d);  // 日にちによって見栄えを変える -(1)
        $add(tr, td);
        d.setDate(d.getDate() + 1);
        if(d.getMonth() == nextMonth) finish = true;
      }
      $add(table, tr);
      if(finish)break;
    }

    /**
     * 日にち表現用td要素を、その日にちによって見栄えが変わるよう
     * class属性を設定する -(1)
     */
    function setClassName(td, d){
      var buffer = [];
      if(d.getMonth() == TODAY_MONTH){
        buffer.push(css("onDate"));
        buffer.push(css(DAYS[d.getDay()]));
        if(d.getDate() == TODAY_DATE){
          buffer.push(css("today"));
        }
      } else {
        buffer.push(css("outDate"));
      }
      td.className = buffer.join(" ");
    }
  }

  /**
   * GM_addStyle関数を使ってCSSを設定する -(2)
   */
  function setStyle(){
    var style =
      <><![CDATA[
                 #_gpanel{
                   margin:0px;
                   padding:0px;
                   position:fixed;
                   top:0;
                   left:0;
                   z-index:999999;
                   width:10px;
                   background:transparent;
                   border-collapse: separate;
                 }
   // 長いので中略
                 #_gcal_ td._gcal_today {
                   color : gold;
                   font-weight : bold;
                   border-top : 1px solid white;
                   border-left : 1px solid white;
                   border-bottom : 1px solid #666666;
                   border-right : 1px solid #666666;
                   background-color:#FFFFEE;
                 }
                 ]]></>;
    GM_addStyle(style);
  }
}

// CSSで左上隅に配置するようにしたので、bodyの末尾要素に追加してもよい。
// そのためコーディング量が少なくてすむinsertBeforeではなくappendChildにした。
document.body.appendChild($add($div({id:"_gpanel"}), makeCalendarTable()));

基本的な処理方法は図7と変わっていません。変更点は大きく見ると2点で、⁠1)CSS用にid属性とclass属性を各要素に追加する処理を加えたことと、⁠2)GM_addStyle関数を使ってCSSを設定するsetStyle関数を追加したこと、です。

図8内に(1)⁠2)の変更点に関わる箇所についてコメントでも示しておきました。⁠2)のsetStyle関数ではE4Xで<></>の子要素としてCDATAセクションを使うことでヒアドキュメントライクにCSSを記述しています。また、そこで定義しているCSSでスクロールしても常に画面の左上隅に表示されるようにプロパティを設定しています。

以上で第2の不満も解消できました。

キーの押し下げでカレンダの表示/非表示を切り替える

第3の不満は

「常にカレンダが表示されているのは邪魔である。必要なときだけ表示するようにしたい。」

でした。⁠必要なとき」に特定のキーを押すことでカレンダを表示するようにすることで解決することにします。

表示/非表示の切り替えはCSSのdisplayプロパティの値を"block"/"none"に切り替えることで実現します。キーの押し下げイベントのリスナー関数を作成し、特定のキーが押されているときにこの切り替え処理を行えばOKです。

図8のユーザスクリプトの最後の部分をこの処理方式をとるよう、図9のように書き換えます。

図9 キーの押し下げによりカレンダの表示/非表示を切り替えるスクリプト(ユーザスクリプトはこちら
/**
 * documentに対するkeydownイベントの処理設定をする。
 * 第1引数でキーを指定し、
 * 第2引数で指定されたキーが押し下げられたときに実行する関数を指定する。
 * ただし、INPUT要素、TEXTAREA要素に対するキーイベントの場合は何もしない。
 */
var keybind = (function(){
    function add (phrase, func){
      if(phrase instanceof Array){
        phrase.forEach(function(p){
            add(p, func);
          });
      } else {
        document.addEventListener("keydown",
                                  function(event){
                                    var tagName = event.target.tagName;
                                    if(phrase == code(event) &&
                                       !tagName.match(/(INPUT|TEXTAREA)/i)){
                                      func();
                                      event.preventDefault();
                                      event.stopPropagation();
                                    }
                                  }, true);
      }
    }
    function code(event){
      var code = [];
      if(event.shiftKey){
        code.push("S");
      } else if(event.ctrlKey){
        code.push("C");
      } else if(event.altKey || event.metaKey){
        code.push("M");
      }
      code.push(kc2char(event.keyCode));
      return code.join("-");

      function kc2char(kc){
        function between(a,b){
          return a <= kc && kc <= b;
        }
        var _32_40 = "space pageup pagedown end home left up right down".split(" ");
        var kt = {
          8  : "backspace",
          9  : "tab"  ,
          13 : "enter",
          16 : "shift",
          17 : "control",
          27 : "escape",
          46 : "delete",
        };
        return (between(65,90)  ? String.fromCharCode(kc+32) : // a-z
                between(48,57)  ? String.fromCharCode(kc) :    // 0-9
                between(96,105) ? String.fromCharCode(kc-48) : // num 0-9
                between(32,40)  ? _32_40[kc-32] :
                kt.hasOwnProperty(kc) ? kt[kc] : 
                kc);
      }
    }
    return add;
  })();

var gPanel = null; 

/**
 * カレンダの表示/非表示を切り替える。
 * 最初に表示するときまでカレンダの生成処理はしないようにする。
 * そのためカレンダのフレームを表す変数gPanelの初期値をnullで定義しておく。
 */
function toggleCalendar(){
  if(!gPanel){
    gPanel = $add($div({id:"_gpanel"}), makeCalendarTable());
    document.body.appendChild(gPanel);
  }
  with(gPanel.style){
    if(display != "block"){
      display = "block";
    } else {
      display = "none";
    }
  }
}

keybind("S-c", toggleCalendar); 

はじめに、キーの押し下げイベントの処理設定をするための関数keybindを定義しています。カレンダの表示/非表示の切り替え処理一つのためだけにこのようなユーティリティ関数を定義するのは無駄に感じられると思いますが、今後もっと多くの操作をキー操作で行えるようにする可能性も見据えてこの関数を作成しました。

keybind関数の利用方法は簡単で、図8の最後の行のように、キーを示す文字列と、そのキーが押されたときに実行する関数を指定するだけです。キー文字列の指定方法はkeybind関数内で定義している関数codeが返す文字列の形式で、アルファベットや数字だけでなくカーソルキーなども"up","down",left","right"といった文字列で指定でき、修飾キーと組み合わせた場合にも対応できるようにしています。例えばシフトキーを押しながらスペースキー、の場合であれば "S-space" で指定できます。

カレンダの表示/非表示の処理自体は単純で、カレンダのフレームを表すdiv要素のスタイルのdisplayプロパティの値を切り替えるようにしているだけです。

以上で第3の不満も解決できました。

今回のまとめ

最初は質素でしかも2007年8月に固定のカレンダを表示するだけのユーザスクリプトでしたが、キーバインドで表示/非表示を切り替えられる今月カレンダを表示できるユーザスクリプトにまで成長させることができ、ちらっとカレンダを確認したい、というときに使えるようなものにできたのではないかと思います。

今回のポイントは以下の2点です。

  • (1) スクリプトが読みやすく/書きやすくなるよう、ユーティリティ関数を定義するなどの工夫をする
  • (2) CSSで見栄えを設定することはできるが、表示中に含まれるページ内のCSS定義とパッティングしないように工夫する

(1)については、私が作成したユーティリティ関数を使うようにしましょう、ということではなく、読みやすくなるよう・書きやすくなるように工夫しながらスクリプトを書くようにしましょう、ということです。速度性能が要求されるケースでは読みやすさだけを重視したような関数定義はよろしくないと思いますが、それほどの速度性能がGreasemonkeyのユーザスクリプトに要求されることはほとんどないでしょう。

(2)については、ユーザスクリプトで設定するCSSと、表示中のページ内に含まれるCSS定義とバッティングしてしまうと、ユーザスクリプトで生成/挿入したHTML要素の見栄えが崩れしまったり、表示中のページのレイアウトが崩れてしまったりします。CSSで指定するセレクタに使うクラスやIDが表示中のページで使われているものと重なりにくいものになるようにする、などの工夫が必要になります。

次回の予告

次回はこのカレンダを「Greasemonkeyによるアプリケーション」らしくなるよう進化させ、今月だけでなく別の月も表示できるようにしたり、予定情報の管理機能をつけたりしようと思います。

おすすめ記事

記事・ニュース一覧