前回からGreasemonkeyによるアプリケーション開発の題材としてカレンダアプリケーションを挙げ、
今回はさらに二つの機能を追加したいと思います。
(1) 異なる月も表示する(2) 予定情報を登録/表示できるようにする
異なる月の表示機能
異なる月に移動できるようにするため、
「現在の月」
カレンダに追加する機能の検討
早速その実装をはじめようと思うところですが、
まず、
- 予定の年月日
- 予定の内容
予定情報の表示については、
- 予定情報が登録されている日付は色を変えて表示する
- 日付をカーソルキーなどで選択できるようにして、
選択日に予定情報が登録されていればその内容を表示する
また、
ということで、
- 「選択日」
を動かすようにする - 「選択日」
の月に応じたカレンダを表示する
という処理を実装することとします。
選択日の移動機能の実装
図1、
// ==UserScript==
// @name mini_calendar5
// @namespace http://gomaxfire.dnsdojo.com/
// @description the 5th mini calendar
// @include *
// ==/UserScript==
/* ユーティリティ関数郡の定義 */
/* 中略 */
/** document.getElementById() のエイリアス */
function $(id){
return document.getElementById(id);
}
/**
* Element#removeChild()のエイリアス
* $rmに与えた要素を削除する
*/
function $rm(element){
if(element && element.parentNode)element.parentNode.removeChild(element);
}
//----------------------------------------------------
// calendar application
//----------------------------------------------------
var calendar = (function(){ // -(1)
var frame = $div(); // -(2)
var table = null;
// calendar用処理群の中で共通利用する変数群を定義
var curDate = new Date(); //選択中の日付 -(3)
var TODAY = new Date();
var TODAY_YEAR = TODAY.getFullYear();
var TODAY_MONTH = TODAY.getMonth();
var TODAY_DATE = TODAY.getDate();
var CSS_PREFIX = "_gcal_";
function css(name){
return CSS_PREFIX + name;
}
// 選択中の日付セルを処理しやすくするために
// 日付セルにIDを付加する。そのIDをつくる関数 -(4)
function makeDateId(d){
return css([d.getFullYear(),
f(d.getMonth() + 1),
f(d.getDate())].join("-"));
function f(n){
return n < 10 ? "0" + n : n;
}
}
// calendar処理群としてまとめたので
// makeCalendarTableからmakeTableに名称変更
function makeTable(){
var DAYS = "Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" ");
$rm($(css("")));// 別の月に変更する場合のために以前の内容を破棄 -(2)
table = $table({id:css("")}); //-(2)
$add(frame, table); //-(2)
setMonthHeader();
setDayHeader();
setDates();
setStyle();
return frame;
function setMonthHeader(){
// curDateの年/月を使う
var year = curDate.getFullYear(); // -(3)
var month = curDate.getMonth() + 1; // -(3)
$add(table,
$add($tr({className:css("header")}),
$add($th({className:css("header"), colSpan:7}), year + "/" + month)));
}
// 中略
function setDates(){
// curDate自体を動かさないように、
// curDateを複製して表示用Dateオブジェクトとして利用する
var d = new Date(curDate); // -(3)
var nextMonth = d.getMonth() < 11 ? d.getMonth() + 1 : 0;
d.setDate(1);
d.setDate(-d.getDay() + 1);
// 中略
/**
* 年月日が一致したときのみ「今日」の見栄えにする
*/
function setClassName(td, d){
var buffer = [];
if(d.getMonth() == curDate.getMonth()){ // -(3)
buffer.push(css("onDate"));
buffer.push(css(DAYS[d.getDay()]));
} else {
buffer.push(css("outDate"));
}
if(d.getDate() == TODAY_DATE &&
d.getMonth() == TODAY_MONTH &&
d.getFullYear() == TODAY_YEAR){
buffer.push(css("today"));
}
td.className = buffer.join(" ");
}
}
function setStyle(){
var style =
<><![CDATA[
//中略
#_gcal_ td._gcal_select { //-(5)
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);
}
}
var gPanel = null;
function toggleCalendar(){
if(!gPanel) {
gPanel = $add($div({id:"_gpanel"}), makeTable());
$add(document.body, gPanel);
selectDate(curDate);
}
with(gPanel.style){
if(display != "block"){
display = "block";
} else {
display = "none";
}
}
}
/* 図2のA部分がここにくる */
// 外部インタフェースとなるオブジェクトを返す // -(1)
return {toggle:toggleCalendar,
nextMonth:makeGoMonthOffset(1),
previousMonth:makeGoMonthOffset(-1),
goToday:goToday,
nextDate:makeGoDateOffset(1),
previousDate:makeGoDateOffset(-1),
nextWeek:makeGoDateOffset(7),
previousWeek:makeGoDateOffset(-7)
};
})();
/* 図2のB部分がここにくる */
前回からの変更点はいくつかありますが、
(1) カレンダ処理をオブジェクトにまとめたこれまでのユーザスクリプトではカレンダ処理は
「実行時の月のカレンダを表示する」 という処理一つだけだったのですが、 今回からは選択日の移動処理や月の移動処理など複数の処理を定義する必要が出てきました。それら複数の処理を定義するために、 複数の関数を定義するわけですが、 それらの関数は次の二つの種類に分けられます。 (a) カレンダ処理を利用するために呼び出す関数。(b) 関数の定義のために定義した関数。カレンダ処理のために直接呼び出すことはない。
この二種類の関数定義がそのまま混在していると混乱の元ですし、
間違って (b) のタイプの関数を呼び出してしまうと意図せず (a) の処理に影響してしまう可能性もあります。そのため、 (a) のタイプの関数だけを外部から呼び出せるようにし、 (b) のタイプの関数を内部に閉じ込めて外部から呼び出すことができないようにするため、 オブジェクトの形にまとめるようにしました。 (2) 月の変更に応じたカレンダテーブルの削除処理のため、それを囲むdiv要素を加えた 月を変更したときはカレンダを表すテーブルを一旦削除し、
改めて変更した月に応じたカレンダテーブルを生成するように処理を変更しました。そのため、 再生成したカレンダテーブルを配置する 「場所」 が必要になり、 その役目をするdiv要素を置くことにしました。 (3) 選択日に応じてカレンダを生成するようにしたこれまではnew Date()によって得られる実行時の日付
(の月) に応じたカレンダを生成していましたが、 「選択日」 を変数curDateで表すこととして、 その日付に応じてカレンダを生成するようにしました。 (4) 日付セルを処理しやすくするため、IDを付加するようにした 「選択日」 がどれなのか、 表示上分かりやすくするために選択日を示す日付セルのclass属性の値を変えて見栄えを変えるようにしました。この日付セルの見栄えを変更する処理のため、 見栄えを変更すべき日付セルを特定する必要が必要になるわけですが、 この日付セルの特定を簡単に行えるようにするため各日付セルにID属性を付加することにしました。今回追加したユーティリティ関数 「$」 を使うことで、 IDを指定すれば簡単に所望の日付セル要素を取得できるからです。 (5) 選択日のスタイル定義を追加したカレンダテーブル中の日付セルのうちどれが
「選択日」 なのか一目で分かるよう、 スタイル定義を追加しました。
以上、
次に、
図2のA部分は選択日を移動する処理用の関数の定義で、 この関数では(a)必要に応じて月を変更してカレンダテーブルを再生成し、 (a)の処理は選択中の月と指定された月が異なる場合に実行するようにしています。 選択日curDateの日にちを1日に設定してから指定されたdateの月、 (b)の処理は選択日から日付セル要素を特定し、 (c)の処理は指定された日付を新たに選択日に設定し、 なお、 私は一つの関数の定義が長くなり、 ありがたいことにJavaScriptでは関数の内部で関数を定義できるので、 ただし、 本来必要なのは先月、 また今回の実装では先月、 makeGoMonthOffsetの日にち移動用版です。前の日、 月を何度も移動しているとふと今日に戻りたくなるものです。そのための関数を用意しておきました。 以上が今回のユーザスクリプトの主要な部分でした。 最後のB部分は前回導入したkeybind関数を使って選択日の移動のためのキー操作を定義しています。 今回の実装により、 カレンダアプリケーションとしての便利度をちょっとあげることができたと思います。 次は予定情報を登録/ 仕様として書き下すとたった四つの機能なのですが、 そのため、 今回のポイントは以下の四つです。 一ページ目の Greasemonkeyにはいくつか組み込みの関数が定義されていますが、 予定情報の追加ボタン、 ポイント これ以降、 図3はカレンダオブジェクトの変更部分を示しています。 変更点のポイントは以下の四点です。 Observerはオブジェクト間の連係動作用にObserverパターンを使うために定義したオブジェクトです。特定のイベントが発生したときに、 Observerオブジェクトは以下の関数を持ちます。 notify関数はイベントが発生した時点で呼び出すようにします。notify関数の引数をリスナ関数の呼び出し時にも与えるようにしています。リスナ関数の形式 このObserverオブジェクトを導入することで、 関数cssは両者で共有して利用できるようにcalendarオブジェクトの外側で定義するようにしました。 カレンダテーブルの生成の際に、 日付を選択したとき 予定情報を追加、 両関数ともにクラス名を引数にとります。返す関数は、 図4はスケジューラオブジェクトの定義部分を示しています。 スケジューラオブジェクトの定義におけるポイントは以下の4点です。 Greasemonkeyではデータの永続化のための関数GM_ GM_ GM_ 予定情報は(1-1)で示すように変数eventsで管理するようにしました。二重のハッシュ構造にし、 この変数eventsの値をGM_ 外部オブジェクトに対し予定情報を追加、 四ページ目の 四ページ目の dataスキームはRFC2397で規定されているURLのスキームで、 なお、 図5はカレンダオブジェクトとスケジューラオブジェクトの連結処理部分を示しています。 カレンダテーブルの表示/ しかし、 表示/ この関数は四つの関数呼び出しを行っていますが、 スケジューラオブジェクトの、 スケジューラオブジェクトの指定した日付の予定情報を表示する関数を、 カレンダオブジェクトの指定日の日付セルへのクラス名追加処理関数 こちらは カレンダオブジェクトの指定日の日付セルへのクラス名除去処理関数 三ページ目 (1) 念のためですが、 (2) 次回はこのカレンダにGoogle Calendarに登録されている予定情報を表示させるようにしてみます。また、 // ↓↓↓↓ここからA部分 ↓↓↓↓
/**
* dateで選択した日付に移動する
*/
function selectDate(date){ // -(1)
changeMonthIfNeed(date); // -(1-a)
unselect(curDate); // -(1-b)
select(date); // -(1-c)
function changeMonthIfNeed(date){
if(curDate.getMonth() != date.getMonth()){
curDate.setDate(1); // 意図せず二月分移動してしまうのを防ぐため
curDate.setMonth(date.getMonth());
curDate.setFullYear(date.getFullYear());
makeTable();
}
}
function unselect(date){
var preSelect = $(makeDateId(date));
if(preSelect){
preSelect.className =
preSelect.className.replace(/ _gcal_select/, "");
}
}
function select(date){
curDate = new Date(date);
var select = $(makeDateId(date));
select.className += " _gcal_select";
}
}
/**
* offset分だけ月を変更してカレンダテーブルの再生成をする
* 選択日は1日にする
* <外部インタフェース生成用>
*/
function makeGoMonthOffset(offset){ // -(2)
return function(){
if(!isShown())return;
var newDate = new Date(curDate);
newDate.setDate(1);
newDate.setMonth(curDate.getMonth() + offset);
selectDate(newDate);
};
}
/**
* offset分だけ日付を変更してカレンダテーブルの再生成をする
* <外部インタフェース生成用>
*/
function makeGoDateOffset(offset){ //-(3)
return function (){
if(!isShown())return;
var newDate = new Date(curDate);
newDate.setDate(newDate.getDate() + offset);
selectDate(newDate);
};
}
/**
* 実行時の日付に移動する
* <外部インタフェース用>
*/
function goToday(){ //-(4)
if(isShown())selectDate(TODAY);
}
/**
* 選択日の移動処理はカレンダを表示しているときのみ
* 実行したい。そのために表示中か否かを示す関数を用意。
*/
function isShown(){
return gPanel && gPanel.style.display != "none";
}
// ↑↑↑↑ここまでA部分 ↑↑↑↑
// 外部インタフェースとなるオブジェクトを返す(再掲)
return {toggle:toggleCalendar,
nextMonth:makeGoMonthOffset(1), //(2-a)
previousMonth:makeGoMonthOffset(-1), //(2-b)
goToday:goToday,
nextDate:makeGoDateOffset(1), //(3-a)
previousDate:makeGoDateOffset(-1), //(3-b)
nextWeek:makeGoDateOffset(7), //(3-c)
previousWeek:makeGoDateOffset(-7) //(3-d)
};
})();
// ↓↓↓↓ここからB部分 ↓↓↓↓
keybind("S-c", calendar.toggle);
keybind("S-n S-right S-down".split(" "), calendar.nextMonth);
keybind("S-p S-left S-up".split(" "), calendar.previousMonth);
keybind("f right".split(" "), calendar.nextDate);
keybind("b left".split(" "), calendar.previousDate);
keybind("n down".split(" "), calendar.nextWeek);
keybind("p up".split(" "), calendar.previousWeek);
keybind("S-t", calendar.goToday);
// ↑↑↑↑ここまでB部分 ↑↑↑↑
予定情報の登録/
予定情報管理機能の実装におけるポイント
カレンダオブジェクトの変更部分
// ==UserScript==
// @name mini_calendar6
// @namespace http://gomaxfire.dnsdojo.com/
// @description the 6th mini calendar
// @include *
// ==/UserScript==
/* ユーティリティ関数をここで定義するが省略 */
/**
* イベント通知処理用オブジェクト -(1)
*/
function Observer(){
this.init.apply(this, arguments);
}
Observer.prototype = {
init:function(){
this.listeners = [];
},
addListener:function(func){
this.listeners.push(func);
},
notify:function(){
var args = arguments;
this.listeners.forEach(function(func){
var result = func.apply(null, args);
});
}
};
/**
* cssのIDやclass名のprefix付加処理
* calendarとschedulerで利用するためそれらの外側に移動 -(2)
*/
var PREFIX = "_gcal_";
function css(name){
return PREFIX + name;
}
/**
* カレンダオブジェクトの定義
*/
var calendar = (function(){
// 中略
/**
* カレンダの日付セルにクラス名を追加するObserver -(3-1)
* <外部インタフェース用>
*/
var classNameObserver = new Observer();
function addSetClassNameListener(func){
classNameObserver.addListener(makeFunc(func));
function makeFunc(func){ // -(3-2)
return function(date, td){
var className = func(date);
if(className && td.className.indexOf(className) < 0){
td.className += " " + className;
}
};
}
}
/**
* 日付選択時の追加処理用Observer // -(3-4)
* <外部インタフェース用>
*/
var selectDateObserver = new Observer();
function addSelectDateListener(func){
selectDateObserver.addListener(func);
}
/**
* dateによって指定された日付セルにクラス名を追加する関数を生成する
* -(4-1) <外部インタフェース用>
*/
function makeAddClassNameToDateCell(className){
return function(date){
var cell = $(makeDateId(date));
if(cell && cell.className.indexOf(className)<0){
cell.className += " " + className;
}
selectDate(date);
};
}
/**
* dateによって指定された日付セルからクラス名を削除する関数を生成する
* -(4-2) <外部インタフェース用>
*/
function makeDeleteClassNameFromDateCell(className){
var regexp = new RegExp(" " + className);
return function(date){
var cell = $(makeDateId(date));
if(cell) cell.className = cell.className.replace(regexp, "");
};
}
/**
* 日付セル用のIDを生成する
*/
function makeDateId(d){
return css([d.getFullYear(),
f(d.getMonth() + 1),
f(d.getDate())].join("-"));
function f(n){
return n < 10 ? "0" + n : n;
}
}
// 中略
function makeTable(){
// 中略
// 日付セルの見栄えを設定するために
// クラス名を付加するリスナ関数の呼び出しも最後に行う
function setClassName(td, d){
// 中略
classNameObserver.notify(d, td); // -(3-3)
}
}
}
/**
* dateで選択した日付に移動する
*/
function selectDate(date){
changeMonthIfNeed(date);
unselect(curDate);
select(date);
// 中略
function select(date){
curDate = new Date(date);
var select = $(makeDateId(date));
select.className += " _gcal_select";
selectDateObserver.notify(date); // -(3-5)
}
}
// 中略
// 外部インタフェース用関数をオブジェクトにまとめて返す
return {nextMonth:makeGoMonthOffset(1),
// 中略
addSetClassNameListener:addSetClassNameListener,
makeAddClassNameToDateCell:makeAddClassNameToDateCell,
makeDeleteClassNameFromDateCell:makeDeleteClassNameFromDateCell,
addSelectDateListener:addSelectDateListener
};
})();
スケジューラオブジェクトの定義
var scheduler = (function(){
var events = load();
// 予定情報全体 日付をkeyにしたハッシュ。 -(1-1)
// 値は予定情報のIDをkeyにしたハッシュ。
var addEventObserver = new Observer(); // 予定情報を追加したときの付加処理のため
var deleteEventObserver = new Observer(); // 予定情報を削除したときの付加処理のため
function addAddEventListener(func){ // -(2-1)
addEventObserver.addListener(func);
}
function addDeleteEventListener(func){ // -(2-2)
deleteEventObserver.addListener(func);
}
//アイコンの画像バイナリデータ(base64エンコーディングしたもの)-(4)
var ADD_ICON = "data:image/png;base64," +
// 中略 (バイナリデータのbase64文字列が並ぶ)
var DELETE_ICON = "data:image/png,base64," +
// 中略 (バイナリデータのbase64文字列が並ぶ)
var EDIT_ICON = "data:image/png,base64," +
// 中略 (バイナリデータのbase64文字列が並ぶ)
function makeController(){
var cntr = $div({id:css("sche_")});
// 中略 (予定情報表示欄や予定情報登録用フォームのDOMツリーを生成)
}
function selectDate(date){
$(css("sche_year")).value = date.getFullYear();
$(css("sche_month")).value = date.getMonth() + 1;
$(css("sche_date")).value = date.getDate();
show(date);
}
function show(date){
var events = getEvents(date);
$rm($(css("sche_events")));
var eventsTable = $table({id:css("sche_events"),
cellSpacing:1,
cellPadding:0});
$add($(css("sche_events_frame")), eventsTable);
for(id in events){
event = events[id];
if(!eventsTable.firstChild){
$add(eventsTable,
$add($tr(),
$add($td(),"events:")));
}
var deleteButton = makeDeleteButton(event);
var editButton = makeEditButton(event);
var eventDate = [event.year, event.month, event.date].join("/");
var eventDescription = event.description ? event.description :"";
$add(eventsTable,
$add($tr(),
$add($td(),
$add($p(),eventDate),
$add($p(),eventDescription),
deleteButton, editButton)
)
);
}
// 中略 (makeEditButton、makeDeleteButtonの定義)
}
function getEventDate(event){
return new Date(event.year, event.month -1 , event.date);
}
function addEvent(event){
var index = eventIndex(event);
if(!index)return;
var list = events[index] || (events[index] = {});
if(!event.id)event.id = makeEventId();
list[event.id] = event;
save();
addEventObserver.notify(getEventDate(event));
// 中略(makeEventIdの定義)
}
function deleteEvent(event){
var index = eventIndex(event);
if(!index)return;
var list = events[index];
if(!list)return;
delete list[event.id];
if(!existsEvent(list)){
delete events[index];
deleteEventObserver.notify(getEventDate(event));
}
save();
show(getEventDate(event));
// 中略(existsEventの定義)
}
function getEvents(date){
return events[eventIndexByDate(date)] || {};
}
function hasEvents(date){ // -(3)
return (eventIndexByDate(date) in events);
}
// 中略(共通利用関数の定義)
function save(){ // -(1-2)
GM_setValue("events", events.toSource());
}
function load(){ // -(1-3)
return eval(GM_getValue("events", "({})")) || {};
}
// scheduler object
return {selectDate:selectDate,
makeController:makeController,
addAddEventListener:addAddEventListener, // -(2-1)
addDeleteEventListener:addDeleteEventListener, // -(2-2)
hasEvents:hasEvents // -(3)
};
})();
カレンダオブジェクトとスケジューラオブジェクトの連結
/**
* カレンダの表示/非表示処理
* calendarとschedulerを使う処理なので
* その二つから切り離した
*/
var gPanel = null;
function toggleCalendar(){
setPanelIfNeed();
with(gPanel.style){
if(display != "block"){
display = "block";
} else {
display = "none";
}
}
function setPanelIfNeed(){
if(gPanel) return; // gPanelがあれば設定済み
connectCalendarAndScheduler();
gPanel =
$add($table({id:"_gpanel",
cellSpacing:0,
cellPadding:1}),
$add($tr(),
$add($td({id:css("frame")}), calendar.makeTable())),
$add($tr(),
$add($td({id:css("sche_frame")}), scheduler.makeController())));
$add(document.body, gPanel);
setStyle();
calendar.goToday();
function connectCalendarAndScheduler(){
var HAS_EVENTS_CLASS_NAME = css("has_events");
calendar.addSetClassNameListener(function(date){return scheduler.hasEvents(date) ? HAS_EVENTS_CLASS_NAME:"";});
// -(1)
calendar.addSelectDateListener(scheduler.selectDate);
// -(2)
scheduler.addAddEventListener(calendar.makeAddClassNameToDateCell(HAS_EVENTS_CLASS_NAME));
// -(3)
scheduler.addDeleteEventListener(calendar.makeDeleteClassNameFromDateCell(HAS_EVENTS_CLASS_NAME));
// -(4)
}
}
function setStyle(){
var style =
<><![CDATA[
//中略
#_gcal_sche_frame{
background-color:#C3D9FF;
padding:2px;
}
//中略
]]></>;
GM_addStyle(style);
}
}
// 最後にキーバインド定義をするが省略
今回のまとめ
次回の予告