たまに18歳未満の人や心臓の弱い人にはお勧めできない情報が含まれることもあるかもしれない、甘くなくて酸っぱくてしょっぱいチラシの裏。RSSによる簡単な更新情報を利用したりすると、ハッピーになるかも知れませんしそうでないかも知れません。
の動向はもえじら組ブログで。
宣伝。日経LinuxにてLinuxの基礎?を紹介する漫画「シス管系女子」を連載させていただいています。 以下の特設サイトにて、単行本まんがでわかるLinux シス管系女子の試し読みが可能!
最近、JsDoc Toolkitの導入を考えてる。
ツリー型タブのAPI紹介等ではXPIDLっぽい書き方にしてみてるけど、オレオレ表記なので分かってもらえない可能性があるという心配はずっとしている。
ところで、Firefox自体のソースを見ていると以下のような書き方をしているのをよく見かける。
(略)
/**
* Given a starting docshell and a URI to look up, find the docshell the URI
* is loaded in.
* @param aDocument
* A document to find instead of using just a URI - this is more specific.
* @param aDocShell
* The doc shell to start at
* @param aSoughtURI
* The URI that we're looking for
* @returns The doc shell that the sought URI is loaded in. Can be in
* subframes.
*/
function findChildShell(aDocument, aDocShell, aSoughtURI) {
(略)
これはJavaで標準的に使われているJavadocという「ソースコードの中に埋め込まれたコメントを自動的に収集してHTML形式でドキュメントを生成する」仕組みに基づいたもので、同じ形式でコメントを埋め込めるよう、Javadocの仕様に準拠した実装が言語ごとに存在しているようだ。JavaScriptならJsDoc Toolkit、C言語ならGTK-Docが一般的らしい。あとJavaScriptに関してはGoogle Closure ToolsのClosure Compilerも対応しているらしい
事実上の標準としてみんなが見慣れた形式なのであるならば、これに合わせて書くのがいいだろう。と思ったので、とりあえずライブラリとして切り出して公開しているコードにJavadoc形式で使い方の解説を埋め込んでみることにした。
@example
に例を書く時に、例の中に<
とか>
とかのHTML的にまずい文字が含まれていると、出力されるHTMLがぶっ壊れてしまう。JsDoc Toolkit自体のソースを見てみた所(JsDoc Toolkitはそれ自体がJavaScriptで書かれている。Java上で動作するJavaScript実行環境のRhinoの上で動作している。)、ファイルの拡張子でフィルタリングを行っているらしいということが分かった。あと、テンプレートのファイルの方を編集すれば、例に埋め込んだコードのせいでHTMLがぶっ壊れてしまう問題は回避できるようだった。
そういうわけで当面の所はこんな変更を加えて使ってみることにした。以下はjsdoc_toolkit-2.3.2.zipに対する差分です。
diff -ur jsdoc-toolkit-orig/app/lib/JSDOC/JsDoc.js jsdoc-toolkit/app/lib/JSDOC/JsDoc.js
--- jsdoc-toolkit-orig/app/lib/JSDOC/JsDoc.js 2009-01-24 18:42:04.000000000 +0900
+++ jsdoc-toolkit/app/lib/JSDOC/JsDoc.js 2010-08-20 10:17:17.602464000 +0900
@@ -69,7 +69,8 @@
JSDOC.JsDoc._getSrcFiles = function() {
JSDOC.JsDoc.srcFiles = [];
- var ext = ["js"];
+ var ext = ["js", "jsm"];
+ var ignorePattern = /\.test\.js$/i;
if (JSDOC.opt.x) {
ext = JSDOC.opt.x.split(",").map(function($) {return $.toLowerCase()});
}
@@ -89,7 +90,7 @@
}
}
- return (ext.indexOf(thisExt) > -1); // we're only interested in files with certain extensions
+ return (ext.indexOf(thisExt) > -1) && !ignorePattern.test($); // we're only interested in files with certain extensions
}
)
);
diff -ur jsdoc-toolkit-orig/app/run.js jsdoc-toolkit/app/run.js
--- jsdoc-toolkit-orig/app/run.js 2009-01-08 06:32:58.000000000 +0900
+++ jsdoc-toolkit/app/run.js 2010-08-16 17:28:28.673092400 +0900
@@ -337,7 +337,7 @@
if (!path) return;
for (var lib = IO.ls(SYS.pwd+path), i = 0; i < lib.length; i++)
- if (/\.js$/i.test(lib[i])) load(lib[i]);
+ if (/\.jsm?$/i.test(lib[i])) load(lib[i]);
}
}
Only in jsdoc-toolkit: jsdoc.bat
diff -ur jsdoc-toolkit-orig/templates/jsdoc/class.tmpl jsdoc-toolkit/templates/jsdoc/class.tmpl
--- jsdoc-toolkit-orig/templates/jsdoc/class.tmpl 2009-09-03 06:37:31.000000000 +0900
+++ jsdoc-toolkit/templates/jsdoc/class.tmpl 2010-08-18 15:10:02.542253900 +0900
@@ -300,7 +300,10 @@
<if test="data.example.length">
<for each="example" in="data.example">
- <pre class="code">{+example+}</pre>
+ <pre class="code">{+String(example)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')+}</pre>
</for>
</if>
@@ -399,7 +402,10 @@
<if test="member.example.length">
<for each="example" in="member.example">
- <pre class="code">{+example+}</pre>
+ <pre class="code">{+String(example)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')+}</pre>
</for>
</if>
@@ -466,7 +472,10 @@
<if test="member.example.length">
<for each="example" in="member.example">
- <pre class="code">{+example+}</pre>
+ <pre class="code">{+String(example)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')+}</pre>
</for>
</if>
@@ -565,7 +574,10 @@
<if test="member.example.length">
<for each="example" in="member.example">
- <pre class="code">{+example+}</pre>
+ <pre class="code">{+String(example)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')+}</pre>
</for>
</if>
モック(Mock)とスタブ(Stub)の違いがよく分かってなかったんだけど、何が違うのか、そしてモックはどう使う物なのかということを、すとうさんに教えてもらって今更理解した。あとで会社のブログに書くつもりだけど、メモとして要点だけまとめておく。
自分は今まで、とりあえずユニットテストに注力していて、ある意味脅迫観念的な勢いで、ブラックボックス度合いを高くする事を心がけてた。今まではそれでだいたい問題なかった。でも最近になって、ブラックボックステストにしようとすると無理があるというケースにぶち当たるようになった。1つの機能の中でコロコロと遷移する内部状態を、どうにかして検証したいというようなケースが出てきた。
それですとうさんに相談したら、そういう時はモックを使えばいいと言われた。でも、話を聞く限りだとモックというのはテスト対象の実装の中の処理の流れを追う物のようなので、それじゃブラックボックステストにならないじゃないかと思った。それをそのまま言ったら、確かにテストはできるだけブラックボックステストになってた方がいいけど、機能テストやインテグレーションテストのような粒度の大きな単位のテストでは、処理の中で起こる様々な出来事や副作用を色々モニタリングして、すべての処理が期待通りに動いているかどうかを検証しないといけないから、必然的にホワイトボックステストにならざるを得ないと言われた。
それを聞いて、目の覚めるような思いをした。そうか、ブラックボックステストとホワイトボックステストの使い分けはそこが基準になるのか、と。今まで自分がホワイトボックステストを書かずに済んでいたのは、状態の遷移を伴うような機能を作る必要がなかったからだったんだ、テストをどうも書きにくいなあと思っていた機能は本当はホワイトボックステストにしたほうがいい物だったんだ、と。
そんなわけでUxUにモックの機能を実装した。
「JavaScript Mock」で検索するとJSMockとjqmockが上位に出てきたので、最初はそれらを参考にするように(メジャーな実装があるんだったらそれをそのまま取り入れるなりAPIを合わせるようにするのが望ましい)と言われたんだけど、ドットで繋げるメソッドチェインの記法がガンガン出てきて頭パンクした。
もう少し下の方までスクロールするとMockObject.jsというのが出てきて、こっちはファイル全体で3KBに満たない小さなライブラリなので、まずはここから始めることにした。何せコードが短いから、読むのもそんなに苦にはならない。モックの概念を言葉で説明されてもさっぱりだったけど、一通りの処理の流れを見たら、「モックというのは一体何をやらなきゃいけないのか」「どういう振る舞いが期待されているのか」ということがよく分かった。
MockObject.jsと同等の機能を一通り実装した後でもう一度JSMockの方を見たら、なるほどこれはこういう意味だったのかというのがやっと分かった。サンプルコードを見ても、モックという物の意味をそもそもよく知らない時点では、どこからどこまでがJSMockの部分なのかさっぱり分からなかったんだよね。ということで、MockObject.jsに加えてJSMock互換のAPIも付け加えてみた。jqunitの方は……もう別言語だからシラネ。
あと有名なのはJsMockito? これも頑張ったらできるかなあ、というかMITライセンスだしそのままぶち込んだ方が早いか……
先日のブラウザー勉強会で吾郷さんにお会いした際に、JavaScriptには言語の仕様として「例外がどこから投げられたのか」を知る為の仕組みが無いので独自のフレームワークを作る時に困ったという話を伺った。オレ標準JavaScript勉強会で何話せばいいか困ってた所だったので、それをネタに発表させてもらう事にした。
改めてECMAScriptの仕様書を確認してみたけど、確かに3rd Editionと5th Editionでは、例外オブジェクトの機能としてはError.prototype.name
とError.prototype.message
しか定義されていなかった(あとはconstructor
とかtoString()
とかその程度)。Wikipedia(英語の方)のECMAScriptの記事によるとECMAScript 3rd EditionはJavaScript 1.5とJScript 5.5の共通部分を抜き出す形で策定されたようで、実装してない方のIEに合わせてこうなったのか?とも思ったけど、調べてみたらJavaScript 1.5の頃はMozillaもError.prototype.stack
はサポートしてなかった。それ以降にMozillaが独自に拡張した箇所ということのようだ。でも今回調べた限りではOperaもChromeもError.prototype.stack
をサポートしていた(Operaの場合はこれに加えて、少しフォーマットが違うスタックトレースをError.prototype.stacktrace
でも取得できる、ということをedvakfさんに教えていただいた)。メジャーなJavaScript実行環境でこれに対応してないのはIE(IE9PP3を含む)くらいのようだ……と思ったらSafari(Windows版)もサポートしていなかった。
吾郷さんのそれやUxUのようにデバッグを支援するためのフレームワークでは、スタックトレースは欠かせない要素だ。将来のECMAScriptの仕様に取り込まれてくれればいいのになあ、と思う。
勉強会ではせがわようすけさんに突っ込まれたけど、セキュリティのためにはなるべくこういう情報は出さない方がいいものらしい。しかし自分はスタックトレースの何がまずいのかがよく分かっていない。会場では「例えばスタックトレースでスクリプトのURLの中にセッションIDが含まれていたらセッションハイジャックされてしまう危険性がある」という例を教えていただいたけれども、それでもまだピンと来ていなかった。
さらにツッコミを受けたことで「なるほど、クロスドメインの制約を突破されてしまう」という事が問題なのだと分かった。ただ、実際にそれが問題になるケースってほんとにあるのかな? という疑問はまだ残っている。
分かりやすい話として、通常のXMLHttpRequestやiframeでは、別ドメインのドキュメントを読み込む事ができない・読み込んだとしてもその内容にスクリプトからアクセスすることはできない。
var iframe = document.createElement('iframe');
iframe.setAttribute('src', 'http://www.google.co.jp');
document.body.appendChild(iframe);
window.setTimeout(function() {
try{
alert(iframe.contentDocument.body);
}
catch(e){
alert(e);
}
}, 3000);
例えばこういうのは、Error: Permission denied for <http://piro.sakura.ne.jp> to get property HTMLDocument.body from <http://www.google.co.jp>.と言われてエラーになる。しかしスタックトレースにエラー行の詳細な情報が含まれていると、
try {
document.write('<script type="text/javascript" src="http://www.google.co.jp/" async="false" defer="false"></script>');
}
catch(e) {
var contentsFragment = e.stack;
}
とか
try {
var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.setAttribute('src', 'http://www.google.co.jp/');
script.setAttribute('async', 'false');
script.setAttribute('defer', 'false');
document.body.appendChild(script);
}
catch(e) {
var contentsFragment = e.stack;
}
とか
// window.onerrorはECMAScriptの仕様にはない独自拡張
window.onerror = function(aMessage, aSource, aLineNumber) {
// ここでaMessage, aSourceから情報を取れる可能性がある
}
var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.setAttribute('src', 'http://www.google.co.jp/');
script.setAttribute('async', 'false');
script.setAttribute('defer', 'false');
document.body.appendChild(script);
という風にして、別ドメインのドキュメントの内容を部分的にとはいえ読み取れてしまう可能性がある、というわけだ。特に3番目のwindow.onerrorを使う例はMFSA 2010-47: エラーメッセージのスクリプトファイル名からのクロスサイトデータ漏えいで実際に脆弱性になっていたことが確認されていて、既に修正されている。
なお、実際に現行バージョンのFirefox・Opera・Chromeで試してみたところ、1番目・2番目のtry-catchを使った例ではそもそも例外を例外として(そもそも、エラーが発生したのかどうかすら)捕捉できなかったので、今の所問題にはならない。ただ、今は問題にならなくても、今後登場するJavaScriptの実装ではこういう場合でも情報を取れるようになっている可能性はあるので、将来的に脆弱性の原因になるかもしれないという警告は確かにアリだとは思う。という所までは何とか理解できた。
ブラウザのレベルでは、クロスドメインやクロスオリジンになる時だけは例外を出さないとか詳細な情報を出さないとか、そういう対応の仕方はあるだろうけれども、「ECMAScript」の仕様でそこに言及するのは変だろうなあ。というややこしい事情を勘案すると、もう丸ごと全部仕様からドロップしてしまえという判断になるんかなあ。
XPConnectも使えるコマンドラインのJavaScript実行環境で、xpcshellという物がある。XULRunnerをダウンロードするとおまけでついてくる。
xpcshell内ではWindowオブジェクトが無いので、当然だけどWindow.setTimeout()
やWindow.setInterval()
等を使った遅延処理は書けない。でもXPConnectは使えるので、nsITimerの機能を使えば似たようなことはできるはず。
という事で以下のようなスクリプトを書いて-fオプションで読み込ませてみたけど、実はこのままでは期待通りに動いてくれない。
var Cc = Components.classes;
var Ci = Components.interfaces;
function Timeout(aCallback, aDelay) {
this.finished = false;
this.callback = aCallback;
this.init(aDelay);
}
Timeout.prototype = {
init : function(aDelay) {
this.timer = Cc['@mozilla.org/timer;1']
.createInstance(Ci.nsITimer);
this.timer.init(this, aDelay, Ci.nsITimer.TYPE_ONE_SHOT);
},
cancel : function() {
if (!this.timer) return;
delete this.timer;
delete this.callback;
this.finished = true;
},
observe : function(aSubject, aTopic, aData) {
if (aTopic != 'timer-callback') return;
if (typeof this.callback == 'function')
this.callback();
else
eval(this.callback);
this.cancel();
}
};
print('START');
var timeout = new Timeout(
function() {
print('3 SEC AFTER');
},
3000
);
これを実行しても、「START」と出てすぐに終わってしまう。何故かというと、xpcshellはメインスレッドの処理が終わったらその時点でxpcshell自体を終了させてしまうからなのだそうだ。
UxU 0.7.6でも使ってるGecko 1.9以降のスレッド関係の機能を使うと、この問題を解消できる。さっきのスクリプトの末尾に以下を加えて実行すると、今度は「START」と出た3秒後に「3 SEC AFTER」「END」と出てからxpcshellが終了するようになる。
var thread = Cc['@mozilla.org/thread-manager;1']
.getService()
.mainThread;
while (!timeout.finished) {
thread.processNextEvent(true);
}
print('END');
多分nsITimerのタイマー機能以外でも、これ(フラグが立つまでwhileでループ回してメインスレッドを止める)でうまくいくんじゃないかと思う。
XMLHttpRequestも試してみた。
var Cc = Components.classes;
var Ci = Components.interfaces;
var request = Cc['@mozilla.org/xmlextras/xmlhttprequest;1']
.createInstance(Ci.nsIXMLHttpRequest)
.QueryInterface(Ci.nsIDOMEventTarget);
var loaded = false;
request.open('GET', 'http://piro.sakura.ne.jp/', true);
request.addEventListener('load', function() {
print(request.responseText);
loaded = true;
}, false);
request.send(null);
print('REQUESTING...');
var thread = Cc['@mozilla.org/thread-manager;1']
.getService()
.mainThread;
while (!loaded) {
thread.processNextEvent(true);
}
print('END');
これも、ちゃんとレスポンスが帰ってきてから終了した。
前のエントリではoperationHistory.jsの基本的な使い方を説明しましたが、次は、これを使うにあたって理解しておかないといけないポイントを解説しようと思います。
UIに対して行うアンドゥ可能にしたい操作の中には、他のアンドゥ可能にしたい操作を内部で呼び出すものがあるでしょう。例えばブックマークフォルダの内容をまとめてタブで開く時などに使われるtabbrowserのloadTabs()
メソッドは、新しい空のタブを開くaddTab()
メソッドを内部で呼び出しています。「新しいタブを開く」操作と「ブックマークフォルダの内容をまとめてタブで開く」操作をアンドゥ可能にする際は、これらのメソッドに対してそれぞれアンドゥ・リドゥの処理を定義することになります。
このように「アンドゥ可能な処理」同士が入れ子になっている時、operationHistoryは、それらすべてのアンドゥ可能な処理について、実行が始まった順番通りに履歴に登録を行います。例えば「loadTabs()
で3つのタブを開く」という場面では、以下のように処理が行われます。
loadTabs()
実行。アンドゥ可能な操作Aが始まる。
addTab()
実行。アンドゥ可能な操作Bが始まる。
addTab()
実行。アンドゥ可能な操作Cが始まる。
addTab()
実行。アンドゥ可能な操作Dが始まる。
この時の操作B~Dは、操作Aが完了する前に、操作Aの中から呼び出されています。そのため、これらに対応する履歴項目B'~D'は、完了していない操作Aに対応する履歴項目A'の子項目として登録されます。つまり、このような親子関係が形成されます。
他の履歴項目の子項目として登録された履歴項目は、親となる項目の一部として扱われます。子項目になった履歴項目は、アンドゥ可能な操作の履歴の一覧には登場せず、「最大100回までアンドゥ可能」といった場合、子項目の数はそのカウントに含まれないことになります。
この履歴項目A'に対してアンドゥを指示すると、operationHistoryは以下の順で処理を行います。
onUndo()
onUndo()
onUndo()
onUndo()
この時の実行順序は項目の登録時の逆順であることに注意して下さい。なお、後で解説しますが、遅延処理のための仕組みによってこの実行順序は保証されます。前の項目のonUndo()
が終わる前に次の項目のonUndo()
が始まるという事はありません。
逆に履歴項目A'に対してリドゥを指示すると、operationHistoryは以下の順で処理を行います。
onRedo()
onRedo()
onRedo()
onRedo()
今度は、履歴項目の登録順の通りであることに注意して下さい。こちらについても、実行順序はこの通りに保証されます。
操作をアンドゥできるようにする際は、操作の中から呼び出している別の操作がアンドゥ可能である場合、上記の実行順の事を念頭に置いてアンドゥ・リドゥ用の処理を記述する必要があります。例えば上記の例であれば、アンドゥの際は以下の順でアンドゥのための処理が進みます。
これを見ると、4番目の項目の時点ではもう何もする必要が無いということが分かります。もし4の時点で、3つのタブを開いたのでそのアンドゥ操作としてタブを3つ閉じようとしても、閉じる対象のタブが存在しないためエラーになってしまいます。他のアンドゥ可能な操作を内部で呼び出す操作については、アンドゥ・リドゥの内容が重複しないように注意してください。
Undo Tab Operationsの核であるoperationHistory.jsは、タブに限らずいろんな操作に対してアンドゥ・リドゥを実装しやすくするための汎用のライブラリとして設計しています(一応)。こいつの使い方を、自分の頭の中の整理も兼ねて少しずつ解説していこうと思います。
まず、読み込みの方法。以下のようにJavaScriptのファイルを読み込ませるだけでOKです。
<script src="lib/operationHistory.js"
type="application/javascript"/>
複数のアドオンでこのライブラリを読み込んでいる場合、最もリビジョンの新しい物が使われます。今後はAPIは変えないor後方互換を維持していくつもりなので、使う方はあんまり気にしないで使えるはずです。
operationHistoryの各機能には window['piro.sakura.ne.jp'].operationHistory
でアクセスできます。以下の説明ではサンプルコードを短くするために、 OH
という変数でこれを参照しているものとします。
var OH = window['piro.sakura.ne.jp']
.operationHistory;
operationHistoryを使って任意の処理をアンドゥ・リドゥ可能にしたい時は、その処理を以下のように実行するようにします。
OH.doOperation(function() {
// 任意の処理
});
doOperation()
の引数に関数を渡すと、それがその場で実行されます。実行時のthis
はOH
自身を指していますが、普通に分かりにくいんで、クロージャを使うなり何なりして好きなように書くといいと思います。
var MyAddon = {
myFeature : function() {
var self = this;
OH.doOperation(function() {
self.myInternalMethod();
});
},
myInternalMethod : function() {
// 何かの処理
}
};
で、これだけだとまだアンドゥ・リドゥはできません。doOperation()
に対して以下の引数をさらに指定してやる必要があります。
"MyAddonOperations"
とかそんな感じで好きに名前を付けて下さい。省略すると、履歴の対象のウィンドウが指定されている場合は "global"
、そうでなければ "window"
になります。"TabbarOperations"
という名前を指定してます。window
です。省略すると、ウィンドウ単位の履歴ではなく、クロスウィンドウな単一の履歴となります。が、動作が怪しいので今の所はウィンドウ単位での使い方だけ推奨しておきます。やり直し可能にしたい処理自体と合わせると、doOperation()
は最大で4つまでの引数を取るという事ですね。引数は全部型が違う(関数、文字列、DOMWindow、オブジェクト)ので、doOperation()
はそれらを受け取った後に、どれがどれなのかを自動的に判別します。なので引数はどの順番で指定しても構いません。
実際のアンドゥ・リドゥ処理は、履歴エントリになるオブジェクトのプロパティとして関数で定義します。
var entry = {
// 内部名
name : "undotab-addTab",
// メニュー等に表示する「やり直す処理の名前」
label : "タブを開く",
// アンドゥ時に実行される内容
onUndo : function(aParams) {
gBrowser.removeTab(this.tab);
},
// リドゥ時に実行される内容
onRedo : function(aParams) {
this.tab = gBrowser.addTab();
},
// 以下、任意のプロパティを好きなようにどうぞ
tab : null
};
OH.doOperation(
function() { // やり直し可能にする処理
entry.tab = gBrowser.addTab();
},
'MyAddonOperations', // 履歴名
window, // 処理対象ウィンドウ
entry // 履歴エントリ
);
onUndo
という名前のプロパティで関数を定義しておくとアンドゥ時にそれが実行されます。onRedo
はリドゥの時に実行されます。どちらもthis
は履歴エントリのオブジェクト自身になりますので、まあ見た通りで分かりやすいんじゃないかと思います。クロージャ使って書いても全然構いません。
name
、label
は、文字列で好きなように名前を付けて下さい。定義しなくても使えますが、デバッグの時にはあると便利ですし、DOMイベントを使った記法(後で解説します)の時には無いと困ります。
上記のようにして履歴に登録した処理は、以下のようにしてアンドゥ・リドゥできます。
// アンドゥ
OH.undo('MyAddonOperation', window);
// リドゥ
OH.redo('MyAddonOperation', window);
undo()
とredo()
の引数には、doOperation()
に対して指定したものと同じ履歴名と処理対象のウィンドウを渡します(こちらも引数の指定順は任意です)。ここではまだ「タブを開く操作」しか書いていませんが、同じ履歴名で「タブを閉じる操作」「タブを移動する操作」などに対してそれぞれアンドゥ・リドゥの処理を書いてやれば、線形にそれらをアンドゥ・リドゥできるようになります。
また、「戻る」「進む」のドロップダウンメニューで項目を指定してそこまで一気に飛ぶのと同じように、goToIndex()
で履歴項目のインデックスを指定してそこまで一気にアンドゥする・リドゥする事もできます。
// 現在の位置を得る
var history = OH.getHistory('MyAddonOperation', window);
var current = history.index;
OH.goToIndex(current-3, 'MyAddonOperation', window);
getHistory()
は、登録済みの履歴項目の全エントリを格納したオブジェクトを取得するメソッドです。それで取得したオブジェクトのindex
プロパティで現在のフォーカス位置を得られるので、上の例ではそこから3つ手前に飛ぶ事になります。
ここまでの説明で既に疑問に思った人もいると思いますが、例えばこんな場合。
function NewTab() {
var entry = {
name : "undotab-addTab",
label : "タブを開く",
onUndo : function(aParams) {
gBrowser.removeTab(this.tab);
},
onRedo : function(aParams) {
this.tab = gBrowser.addTab();
gBrowser.selectedTab = this.tab;
},
tab : null
};
OH.doOperation(
function() {
entry.tab = gBrowser.addTab();
gBrowser.selectedTab = entry.tab;
},
'MyAddonOperations',
window,
entry
);
}
function MoveTab(aTab) {
var entry = {
name : "undotab-moveTab",
label : "タブを移動する",
onUndo : function(aParams) {
gBrowser.moveTabTo(this.tab, this.oldPosition);
},
onRedo : function(aParams) {
gBrowser.moveTabTo(this.tab, this.newPosition);
},
tab : null
};
OH.doOperation(
function() {
entry.tab = aTab;
entry.oldPosition = aTab._tPos;
gBrowser.moveTabTo(aTab, 3);
entry.newPosition = aTab._tPos;
},
'MyAddonOperations',
window,
entry
);
}
NewTab()
を実行→それで開かれたタブに対してMoveTab()
を実行→アンドゥ→アンドゥ→リドゥ→リドゥ という順に操作すると、2つ目の履歴項目のリドゥ時にタブが見つからないせいでエラーになってしまいます。こうならないように、処理対象の要素は固有のIDなどで識別してやらないといけません。
operationHistoryにはそのために、要素に対して一意なIDを自動的に付与してそれを元に要素を検索する仕組みがあります。先の例を安全に書くと、以下のようになります。
function NewTab() {
var entry = {
name : "undotab-addTab",
label : "タブを開く",
onUndo : function(aParams) {
var tab = OH.getElementById(this.tab,
gBrowser.mTabContainer);
gBrowser.removeTab(tab);
},
onRedo : function(aParams) {
var tab = gBrowser.addTab();
OH.setElementId(tab, this.tab)
gBrowser.selectedTab = tab;
},
tab : null
};
OH.doOperation(
function() {
var tab = gBrowser.addTab();
entry.tab = OH.getElementId(tab);
gBrowser.selectedTab = tab;
},
'MyAddonOperations',
window,
entry
);
}
function MoveTab(aTab) {
var entry = {
name : "undotab-moveTab",
label : "タブを移動する",
onUndo : function(aParams) {
var tab = OH.getElementById(this.tab,
gBrowser.mTabContainer);
gBrowser.moveTabTo(tab, this.oldPosition);
},
onRedo : function(aParams) {
var tab = OH.getElementById(this.tab,
gBrowser.mTabContainer);
gBrowser.moveTabTo(tab, this.newPosition);
},
tab : null
};
OH.doOperation(
function() {
entry.tab = OH.getElementId(aTab);
entry.oldPosition = aTab._tPos;
gBrowser.moveTabTo(aTab, 3);
entry.newPosition = aTab._tPos;
},
'MyAddonOperations',
window,
entry
);
}
getElementId()
は、要素に一意なIDが付いていなければ新しいIDを生成て設定した上でそのIDを、既にIDが付いていればその値を、文字列として返します。IDは普通のid属性ではなく別の属性名で保存されるので、通常の動作を破壊することはありません。
getElementById()
は、そのID文字列をキーとして要素を検索するメソッドです。tabbrowserの場合はタブなどの内部の要素は普通のdocument.getElementById()
等では取得できないのですが、getElementById()
はID名の文字列以外に要素ノードを渡すと、その要素の子孫だけを検索するようになります。
ここでは、タブを開く操作のリドゥにおいてsetElementId()
も使用しています。これは、既に生成されたID文字列を新しく復元された要素に付与することで、その要素を元の要素の代わりとして参照できるようにするためです。
とりあえず、まずはこの辺だけ解説しておきます。
FUEL廃止はJetpackの台頭と対になっているよ、という話。
FUELとJetpackは、コンセプトが確かに重複してるんですよね。
違うのは、FUELはあくまで既存の「拡張機能」という枠組み(JavaScriptやXULやCSSを使って、XPI形式にして、云々)の中でそれを達成しようとしていたのに対して、Jetpackは「そもそもXULとか要らなくね? JavaScriptだけでよくね?」とちゃぶ台をひっくり返してその目的に特化した物をゼロから作った所だと思う。
「Firefoxに機能を追加するなら拡張機能を使いましょう」というのは、実装の仕方をなるべく1種類にまとめて世界を分かりやすくしたいという、開発者目線の考え方のように思う。そうではなく、「Firefoxに機能を追加できるなら、拡張機能でもGreasemonkeyでもUbiquityでもJetpackでもStylishでも何でもイイじゃん。なんで拡張機能に拘らないといけないわけ?」とユーザー目線の発想で考え直したら、FUELみたいな子供だましではなく、Jetpackのように実行環境を1個丸ごと作りなおす事になりました、と。そういう話の流れなのだと僕には思えた。
既存の拡張機能の作り方に特化して知識を溜め込んできた僕にとっては、はっきり言って恐怖ですらある。自分の存在価値、アイデンティティを揺るがす事態だ。だって、大学在学中からMozillaにのめり込んで、それが縁で就職までしてしまって、今もそれをネタに仕事してるんだもの。
そんな僕にとっては、Jetpackが優遇されてFUELが廃止されるというのは、今まで自分が慣れ親しんでいたやり方が否定されて、開発元からも邪険にされて、お前らは時代遅れなんだよプギャーと指さして笑われて、時代の変化についていけない奴らは死ねばいいじゃないと見捨てられてる、正直そんな気分です。
でも、「今までのやり方が全く通用しない世界に飛び込んでいく」事を恐れて今いる所に留まり続けるのは、それこそ座して死を待つだけだという事も分かってる。
それに、そういう「今までのやり方が全く通用しない世界」を作るのであれば、どうせなら、今までのしがらみから完全に切り離された理想の世界を作って欲しいとも思う。XULやXPConnectといった古いやり方しか知らない僕みたいな人間のために「ほうら今までのやり方も使えるんですよ? だから怖がらないで入ってきて下さいよ」と媚びを売って、古いしがらみまでまた抱え込んでしまうのは、やめて欲しい。
僕はそう考えているので、だから今のJetpackでXULやXPConnectを触れるのは非常にマズイ事態だと思ってる。せっかく理想郷を作ろうとしてるんだったら、そこにつまんないしがらみを持ち込んでくれるなよと。僕みたいなロートルが、新しく出てきたJetpackという世界を、古臭い駄目なやり方で汚していってしまう事が許せないのです。老兵は潔く去らないといけないし、潔く去らせないといけないじゃないか。老兵に取り付く島を与えちゃ駄目じゃないか。そんなことをしたら、僕みたいにだらしのない老兵は甘えてしがみついちゃうじゃないか。
まあそれはさておき、FUELを出してきた時に「これで簡単に作れるようになれますよ」「安定したAPIが提供され続けますよ」なんて夢みたいな事を吹聴していた人達には、「すんません自分はバカでした。見る目がありませんでした。」と謝罪して欲しい所ですよね。それを真に受けて酷い目にあった被害者までいるんだし。うん、僕のことですね。ごめんなさい……
ツリー型タブのAPIでこんなのが欲しいというのがあったら言っておくれと書いたところ、Alice0775さんからタブバーのドラッグ操作とかツリーのドラッグ操作とかをアンドゥする機能が欲しい(大意)という要望をいただいた。
そういえばこの前のMozilla勉強会の後の懇親会で、新しいタブが開かれたり新しいウィンドウが開かれたりした時に「戻る」ボタンで元のタブや元のウィンドウに戻れないことについて、あらゆる操作がアンドゥ可能になってないといけないんじゃないの?とかそんな感じの話が出ていたと思う。なので実験的にそういう物を作り始めてみた(自動テスト)。
var current = TreeStyleTabService.currentTabbarPosition;
window['piro.sakura.ne.jp'].operationHistory.doUndoableTask(
// やり直し可能にしたい処理
function() {
TreeStyleTabService.currentTabbarPosition = newPosition;
},
// 履歴の名前(省略可)
'TabbarDNDOperations',
// ウィンドウごとの履歴の場合の対象ウィンドウ(省略可)
window,
// 履歴の項目
{
// 項目名
label : 'タブバーの位置変更',
// アンドゥの時に実行する内容
onUndo : function() {
TreeStyleTabService.currentTabbarPosition = current;
},
// リドゥの時に実行する内容(省略可)
// →省略時は上記の「やり直し可能にしたい処理」が自動的に
// onRedoとして登録される
onRedo : function() {
TreeStyleTabService.currentTabbarPosition = newPosition;
}
}
);
という感じでアンドゥ・リドゥ時の動作を登録して、window['piro.sakura.ne.jp'].operationHistory.undo('TabbarDNDOperations', window)
とかwindow['piro.sakura.ne.jp'].operationHistory.redo('TabbarDNDOperations', window)
とか書くとヨロシク処理してくれる……という風な感じ。「あらゆる操作を一次元で記録して」とタイトルに書いてるけど、自動的に記録するんじゃなくてアドオン作者が手作業で記録する前提で、「履歴の記録」「履歴の呼び出し」の所を管理する手間を軽減するだけのライブラリなんで、そこの所はお間違えなきよう。
関数をそのまま登録するというのが乱暴と言えば乱暴なんだけど、柔軟性を高くしようと思ったらこうするのが手っ取り早いかなーって思いまして。一応ウィンドウごとの履歴とグローバルな履歴の両方を持てるようにしてみてる。イメージ的には、Adobe製品のヒストリ機能のような物を目指してる。
で、枠組みは用意したんだけど、タブバーの位置の移動みたいな単純な機能はいいとして、ツリーの移動みたいなややこしい物をどうやってアンドゥ・リドゥさせるかで暗礁に乗り上げてる。
なんとなく、ツリー型タブからは分離して「タブバー上のあらゆる操作をアンドゥ可能にするアドオン」を新しく作った方がいいような気がしてきた。で、ツリー型タブが入ってる時はそいつのアンドゥ履歴の中に「タブバーの位置変更」の項目が混ざってくる、みたいな連携の仕方。
ツリー型タブに他のアドオン向けのAPIを加えていきたいという話を書いた関係でコードをあちこち見直して書き直していて、カスタムイベントを通知→イベントを捕捉した側でキャンセル→イベントを通知した側でキャンセルを検知して処理を中断 という事をやりたくなって調べた結果をmodestにまとめてみた。
ここに書かずにmodestの方に書いたのは、話のレベル的に今更感があったのと、わりと基礎的な話だからこんな世界の果てのオメガギークの日々の下らない愚痴に混ぜて書くだけにしたら誰にも見てもらえないんじゃないか(それよりももっと多くの人に読んで貰って取り入れてもらって、アドオン同士の連携を取りやすい世の中になってくれると嬉しい)と思ったというのと、というあたりが理由です。
でもなんかほんと今更過ぎるというかものすげえ基本的なことを得意げに解説してしまった気がしていて、失敗だったかなあとちょっとブルーです。
ツリー型タブとClose tab by double clickの競合について報告をもらった。
向こうのコードを見てみたら、タブバーの上でダブルクリックされた時にそのイベントがタブの中で発生したものかどうかを検出するのにevent.originalTarget
とそのparentNode
だけを見ていて、ツリー型タブによって追加されたバインディングがあると判別に失敗するようになっていた。これはツリー型タブだけの問題じゃなく、バインディングを加えるあらゆるアドオンと衝突の可能性があるし、テーマによっても衝突する。バインディングに変更を加えなくても、タブの中に何か要素を追加するアドオンは全部衝突する。
いいかげん、こういう時にはDOM3 XPathを使うのが常識になってて欲しいです。こんな所で他のアドオンと衝突する可能性を残す必要はない。
clicked : function(event) {
if (gBrowser.mTabs.length <= 1) return;
var tab = document.evaluate(
'ancestor-or-self::*[local-name()="tab"][1]',
event.originalTarget,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue;
if (tab) gBrowser.removeTab(tab);
}
こういう風に書けば、クリックされた要素の祖先まで辿って確実に判別できる。他にも絞り込みの条件を付けたければ付けられる。
「シンプルに作る事」と「手抜き」とは、必ずしも一致しませんよね。