Now browsing the SCRAPBLOG weblog archives.

カスタムツリービューの基本的な使い方(その5~ドラッグ&ドロップ)

ツリーのアイテムをドラッグ&ドロップして移動できるようにする。ただし一度にドラッグ&ドロップ可能な行数は1とする。つまり、複数選択してのドラッグ&ドロップは不許可とする。
ベースとなるソースコードはその1~表示を参照。

fruits.xul

tree か treechildren 要素に対して、ドラッグ開始時に発生する ondragstart イベントハンドラを追加する。 handleDragStart 関数については後述。

    <tree id="fruitsTree"
          ondragstart="handleDragStart(event);"
          flex="1">

fruits.js

はじめに handleDragStart 関数を実装する。この関数では、まず dragstart イベント発生元が treechildren 要素であることを確認し、それ以外の場合のイベント(ツリーのスクロールバーのドラッグ)を無視する。ただし、 treechildren 要素に ondragstart イベントハンドラを付加した場合、この処理は不要となる。次に、現在選択している行数が1である場合のみ、現在の行番号(つまりドラッグ元の行番号)を取得し、その値を text/x-moz-tree-index という固有のデータ型で、ドラッグ&ドロップの転送データとしてセットする。この処理は Firefox 3.5 で導入された新しいドラッグ&ドロップAPIを使って実装されている。 Firefox 3.0 以下に対応させる場合、 nsDragAndDrop.js を使ったレガシーな実装方式が必要となる。

function handleDragStart(event) {
    // ignore when dragging scrollbar
    if (event.target.localName != "treechildren")
        return;
    // disallow dragging multiple rows
    if (gFruitsTreeView.selection.count != 1)
        return;
    // set current row index to transfer data
    var sourceIndex = gFruitsTreeView.selection.currentIndex;
    event.dataTransfer.setData("text/x-moz-tree-index", sourceIndex);
    event.dataTransfer.dropEffect = "move";
}

前述のドラッグ&ドロップAPIでは、ドラッグ開始時に発生する dragstart イベントの他に、ドラッグオーバー時に発生する dragover イベントやドロップ時に発生する drop イベントなどもある。しかし、ツリー内の行のドラッグ&ドロップを実装する場合はドラッグ時の処理だけを tree 要素側に実装し、ドラッグオーバー時とドロップ時の処理は nsITreeView 側に実装する。

ツリーへのドラッグオーバー時は、 nsITreeView#canDrop メソッドが呼び出され、このメソッドで true を返すと現在の位置に対するドロップが可能であることを示すアンダーラインが表示され、 false を返すとマウスポインタが駐車禁止の標識のようなアイコンへと変わり、ドロップが不可であることが示される。 canDrop メソッドへ渡される第1引数 targetIndex はドロップしようとしている位置の行番号、第2引数 orientation はその行の前後どちらに対してドロップしようとしているかを示す値で、 nsITreeView で定義されている3つの定数、 DROP_BEFORE (-1) 、 DROP_ON (0) 、 DROP_AFTER (1) のうちのいずれかである。ただし、 DROP_ON は階層構造があるツリーのコンテナ(いわゆるフォルダ)へのドラッグオーバー時にセットされるので、今回は考慮する必要はない。第3引数 dataTransfer は nsIDOMDataTransfer オブジェクト(dragstart イベントなど発生時の event.dataTransfer に相当)である。ただし、第3引数は Firefox 3.5 以前では渡されないので、ここからは Firefox 3.6 以上を前提として話を進める。なお、 Firefox 3.5 に対応させるためには nsIDOMDataTransfer の代わりに nsIDragSession から転送データの値を取得するやや面倒な手順が必要となる

canDrop メソッドではまず dataTransfer からドラッグされた転送データ内に text/x-moz-tree-index データ型のデータが存在するかを確認する。存在しない場合、ツリーアイテム以外の何らかのデータがドラッグオーバーされたということなので、 false を返してドロップを不許可にする。さらに、複数のツリーアイテムのドロップを不許可、現在の行と同一の行前後へのドロップは無意味なため不許可にする。

下記のコード中にデバッグ用の dump 関数を仕込んだので、コンソールをみながらドラッグして具体的な引数の値を調べるとわかりやすい。

    canDrop: function(targetIndex, orientation, dataTransfer) {
        dump("canDrop(" + targetIndex + ", " + orientation + ")
");
        if (!dataTransfer.types.contains("text/x-moz-tree-index"))
            return false;
        if (this.selection.count != 1)
            return false;
        var sourceIndex = this.selection.currentIndex;
        if (sourceIndex == -1)
            return false;
        if (sourceIndex == targetIndex)
            return false;
        if (sourceIndex == (targetIndex + orientation))
            return false;
        return true;
    },

ツリーへのドロップ時は、 nsITreeView#drop メソッドが呼び出される。引数は canDrop メソッドと同様である。下記コードでは、まず前述の canDrop メソッドを使ってドロップが可能であることをチェックする。次に、ドロップ先の行番号 targetIndex の値をドロップした後の状態でのそのアイテムの行番号へと補正している。この補正は以下のような条件判断で行われる。
(1) ドラッグ元の行よりも下にある行の前へドロップした場合、アイテム移動によって行番号が1減ることを考慮する
(2) ドラッグ元の行よりも上にある行の後へドロップした場合、その行のすぐ下の位置へ移動する
この条件判断は頭で考えるだけでは難しいので、 canDrop メソッド同様に dump 関数を使ってデバッグを行うと良い。特に階層構造を持ったツリーでは条件判断がかなり複雑になる。
ドロップ後のツリーアイテムの行番号を求めたあとは、新たに追加する自前の moveItem メソッドによって実際のデータ内のアイテム移動およびツリーの表示更新を行う。

    drop: function(targetIndex, orientation, dataTransfer) {
        if (!this.canDrop(targetIndex, orientation, dataTransfer))
            return;
        var sourceIndex = this.selection.currentIndex;
        if (sourceIndex < targetIndex) {
            if (orientation == Components.interfaces.nsITreeView.DROP_BEFORE)
                targetIndex--;
        }
        else {
            if (orientation == Components.interfaces.nsITreeView.DROP_AFTER)
                targetIndex++;
        }
        this.moveItem(sourceIndex, targetIndex);
    },

ドロップ時に drop メソッドから呼び出す moveItem メソッドでは、データ _data 配列の位置 aSourceIndex の要素を、位置 aTargetIndex へと移動させ、ツリーの表示を更新して移動した要素に対応する行を再度選択状態にする。

    /**
     * @param Number aSourceIndex The array index wherefrom move.
     * @param Number aTargetIndex The array index whereto move.
     */
    moveItem: function(aSourceIndex, aTargetIndex) {
        if (aTargetIndex < 0 || aTargetIndex > this.rowCount - 1)
            return;
        var removedItems = this._data.splice(aSourceIndex, 1);
        this._data.splice(aTargetIndex, 0, removedItems[0]);
        this._treeBoxObject.invalidate();
        // select moved item again
        this.selection.clearSelection();
        this.selection.select(aTargetIndex);
        this._treeBoxObject.ensureRowIsVisible(aTargetIndex);
        this._treeBoxObject.treeBody.parentNode.focus();
    },

応用例1~ボタンによる移動~

Firefox の「検索バーの管理」のように、「上へ」「下へ」のボタンによって移動できるようにする。
まずは fruits.xul へ以下のように Up / Down ボタンを追加する。

    <vbox>
        <button label="Up" oncommand="bumpFruit(-1);" />
        <button label="Down" oncommand="bumpFruit(1);" />
    </vbox>

次に、ボタンクリック時に呼び出される bumpFruit 関数を実装する。

function bumpFruit(aUpDown) {
    if (gFruitsTreeView.selection.count != 1)
        return;
    var sourceIndex = gFruitsTreeView.selection.currentIndex;
    var targetIndex = sourceIndex + aUpDown;
    gFruitsTreeView.moveItem(sourceIndex, targetIndex);
}

応用例2~ドラッグ&ドロップの転送データを利用する~

ここまでは「ドラッグ元のツリー行=ドロップ時に選択されたツリー行」という前提で nsITreeView#canDrop および drop メソッドを実装したが、ドラッグ開始時にドラッグ元の行番号を転送データとしてセットしているので、これを利用する手もある。 nsITreeView#canDrop および nsITreeView#drop は以下のように修正される。

-        var sourceIndex = this.selection.currentIndex;
+        var sourceIndex = parseInt(dataTransfer.getData("text/x-moz-tree-index"));

今回のような階層構造の無いツリーでは応用例2の方式で問題ないが、階層構造のあるツリーではドラッグ中にフォルダ上にマウスオーバーするとフォルダが自動的に開いてツリーの行番号に狂いが生じる場合があるため、応用例2の方式が使えない。

関連記事

TOP

カスタムツリービューの基本的な使い方(その4~並び替え)

ツリーカラム(”Name” と表示されている部分)をクリックしたときに、ツリーの表示内容を並び替えする機能を追加する。
ベースとなるソースコードはその1~表示を参照。

3つのソート状態

一般的に、ツリーカラムをクリックするたびに下表に挙げた3通りのソート状態が循環される(例えば「ブックマークの管理」)。
RDF データソースから生成するツリーの場合、treecol 要素の sort 属性へ並び替えのキーとなるプロパティの URI を記述するだけで半自動的に並び替えが可能となるが、カスタムツリービューを使用したツリーの場合、ツリーカラムがクリックされるたびに treecol 要素の sortDirection 属性を変更してツリーカラムの表示を変更し、なおかつ適切な順序で内部的に保持しているデータ自体も並び替えてツリーの表示を更新してやる必要がある。

ソート状態 sortDirection 属性 ツリーカラムの表示
未ソート natural マークなし
昇順 (A – Z) ascending ▽マーク表示
降順 (Z – A) descending △マーク表示

fruits.js

ツリーカラムがクリックされると nsITreeView#cycleHeader メソッドが呼び出される。
引数 col にはクリックしたツリーカラムに対応する nsITreeColumn 型オブジェクトが渡されるので、ここから treecol 要素の sortDirection 属性値を取得し、新しいソート状態へ変更する。実際の並び替え処理は、後述する自前のメソッド sortItems で行う。

    cycleHeader: function(col) {
        // change sort direction
        var sortDir = col.element.getAttribute("sortDirection");
        switch (sortDir) {
            case "ascending" : sortDir = "descending"; break;
            case "descending": sortDir = "natural"   ; break;
            default          : sortDir = "ascending" ; break;
        }
        col.element.setAttribute("sortDirection", sortDir);
        // sort data
        this.sortItems(sortDir);
    },

FruitsTreeView.prototype へ sortItems メソッドを追加する。このメソッドは、引数 aDirection で指定された順序で内部的に保持しているデータ _data を並び替える。
ここで一つ問題があり、配列のデータを昇順なり降順なりで一度並び替えを行ってしまうと、元の順序がわからなくなってしまう。そこで、やむを得ず _originalData として元の順序の配列データをコピーしてバックアップするようにする。最後に、 nsITreeBoxObject.invalidate を呼び出すことでツリーの描画を更新する。

    /**
     * sort items in an order specified as aDirection
     * @param String aDirection "ascending", "descending" or "natural".
     */
    sortItems: function(aDirection) {
        // clone data to backup
        if (!this._originalData)
            this._originalData = this._data.concat();
        switch (aDirection) {
            case "ascending": 
                this._data.sort();
                break;
            case "descending": 
                this._data.sort();
                this._data.reverse();
                break;
            case "natural": 
                // restore from original data
                this._data = this._originalData.concat();
                this._originalData = null;
                break;
        }
        // refresh tree
        this._treeBoxObject.invalidate();
    },

isSorted メソッドの実装

nsITreeView インタフェースにはツリーが現在並び替えされた状態にあるかどうかを示す isSorted メソッドが定義されており、 IDL の説明によればドラッグ&ドロップ時のフィードバック(ドロップ先を示すインジケータ)関連で使用されるらしい。詳細は不明だが、とりあえずこのメソッドを実装してみる。
効率化のために FruitsTreeView へ自前のプロパティ _sorted を追加し、 cycleHeader が呼び出されたタイミングでこれの値を変更する。そうすれば isSorted メソッドは _sorted の値を返すだけでよい。

    _sorted: false,
    isSorted: function() {
        return this._sorted;
    },
    cycleHeader: function(col) {
        
        /* snip */
        
        this._sorted = (sortDir != "natural");
    },

isSorted メソッドの使い道として、並び替えされた状態でのドラッグ&ドロップによるアイテム移動を禁止するといった使い道が考えられる。並び替えされた状態でのドラッグ&ドロップによる移動を許可すると、ややこしいことになるため、ドラッグしようとした際に isSorted で並び替えされた状態かどうかをチェックし、そうであればドラッグを禁止する。

関連記事

TOP

setTimeout のコールバック関数内でローカル変数を使用する

var fruits = ["apple", "orange", "banana"];

という配列があるとき、

for (var i = 0; i < fruits.length; i++) {
    window.setTimeout(function() { alert(fruits[i]); }, i * 1000);
}

こうすると1秒おきに「undefined」が3回表示されてしまう。コールバック関数が呼び出されたときにはすでにローカル変数 i は破棄されている i の値が3になっているためである。
以下のようにコールバック関数を文字列にしておけば、1秒おきに「apple」「orange」「banana」が表示される。

for (var i = 0; i < fruits.length; i++) {
    window.setTimeout("alert('" + fruits[i] + "');", i * 1000);
}

あるいは、以下のように setTimeout の第3引数でコールバック関数へ引数を渡す方法もある。コールバック関数の内容が複雑になる場合はこの方が良い。 by Piroさん

for (var i = 0; i < fruits.length; i++) {
    window.setTimeout(function(aArg) { alert(aArg); }, i * 1000, fruits[i]);
}

Firefox 2以降、JavaScript 1.7以降限定 by nanto_viさん

for (var i = 0; i < fruits.length; i++) {
    window.setTimeout(let (fruit = fruits[i]) function() { alert(fruit); }, i * 1000);
}

こちらは Firefox 1.0 でもOK by nanto_viさん

for (var i = 0; i < fruits.length; i++) {
    with ({ fruit: fruits[i] }) {
        window.setTimeout(function() { alert(fruit); }, i * 1000);
    }
}

クロージャを使って以下のように書く手もある。 by os0xさん

for (var i = 0; i < fruits.length; i++) {
    (function(fruit){
        window.setTimeout(function() { alert(fruit); }, i * 1000);
    })(fruits[i]);
}

TOP

prefpane には必ず id を付与する

設定値 browser.preferences.animateFadeIn が true の場合に、 id を持たない prefpane が丸ごと表示されない。

<prefpane id="paneMain">

というように必ず id を付与しなければならない。
確かに MDC の prefpane の解説 にも id は必ず付与すべしと書いてある。

prefwindow を使用している場合はパネルの中身が見切れるなどのトラブルがつき物なので、可能な限り以下のような観点での動作確認をした方がよさそうだ。

  • 各パネルの中身が正しく表示されるか?
  • 最後に選択したパネルが次回正しく初期選択されるか?
  • browser.preferences.animateFadeIn が true の場合の動作確認
  • browser.preferences.instantApply が true の場合の動作確認
  • 上記を {Firefox 2 | Minefield} × {Win | Mac | Linux} の6パターンで動作確認

TOP

Firefox 2 / Firefox 3 の判別方法

browser.xul にて Firefox 2 か Firefox 3 かを手っ取り早く調べるには、例えば BookmarksUtils か PlacesUtils が存在することを調べる。

if ("PlacesUtils" in window)
  alert("Maybe Firefox 3.");
if ("BookmarksUtils" in window)
  alert("Maybe Firefox 2.");

より厳密に調べるなら、 nsIXULAppInfo を使う。これなら browser.xul 以外の場所でも可能。

var appInfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
if (appInfo.version.substr(0, 1) == "3")
  alert("Firefox 3");

TOP

カスタムツリービューの基本的な使い方(その3~インライン編集)

Firefox 3 以降限定だが、ツリーのインライン編集が可能となる。この機能を使い、ツリーのアイテムをダブルクリックして果物の名前を変更する機能を追加する。ベースとなるソースコードはその1~表示を参照。

fruits.xul

tree 要素へ editable=”true” 属性を追加する。

    <tree id="fruitsTree" editable="true" flex="1">

fruits.js

nsITreeView#isEditable メソッドを実装する。引数 row で指定された行がセパレータでなければ編集を可能にする。

    isEditable: function(row, col) {
        return !this.isSeparator(row);
    },

ダブルクリックでセルを編集して Enter キー押下で確定させると、 nsITreeView#setCellText メソッドが呼び出される。
setCellText メソッドでは、引数 row で指定されたアイテムのデータそのものを変更してから nsITreeBoxObject#invalidate でツリーを再描画する。

    setCellText: function(row, col, value) {
        this._data[row] = value;
        this._treeBoxObject.invalidate();
    },

関連記事

TOP

Firefox 3 でのポップアップ仕様変更

Firefox 3 (Gecko 1.9) では XUL ポップアップの仕様が大幅に変更される。
Neil’s Place » Blog Archive » XUL Popup Improvements

特に注意すべき点

  1. popup 要素は非推奨となり、その代わりにまったく等価な menupopup 要素を使用することが推奨されている。
  2. panel という汎用のポップアップ型要素が新たに追加される。子に menuitem を配置するなら menupopup 、それ以外の色々なUI部品を配置したければ panel 、というふうに使い分ける。
  3. showPopup メソッドが非推奨となり、代わりに openPopup と openPopupAtScreen メソッドが追加される。
  4. state プロパティが追加され、ポップアップが現在開いているか、閉じているかなどが判別可能になる。
  5. ポップアップを開く動作が非同期的なイベントになる。したがってポップアップが開いた直後に何らかの処理をしたければ、 popupshown イベントハンドラを使用する必要がある。
  6. popup / menupopup 要素の見た目がOSネイティブな見た目になる。

openPopup / openPopupAtScreen メソッド

showPopup メソッドは引数の指定方法がわかりにくく混乱を招いていたが、 Firefox 3 で新たに追加される2つのメソッドは、スクリーンに対する絶対位置へポップアップを開くための openPopupAtScreen と、ある要素に対する相対位置へポップアップを開くための openPopup というように明瞭化されている。
例1) スクリーン上の位置 (100, 200) にポップアップ popupElt を開く

Firefox 2
document.popupNode = null;  // 位置ズレ防止
popupElt.showPopup(document.documentElement, 100, 200, "popup", null, null);
Firefox 3
popupElt.openPopupAtScreen(100, 200, false);

例2) 要素 aAnchorElt の左下にポップアップ popupElt を開く

Firefox 2
popupElt.showPopup(aAnchorElt, -1, -1, "popup", "bottomleft", "topleft");
Firefox 3
popupElt.openPopup(aAnchorElt, "after_start", 0, 0, false, true);

openPopup メソッドの第5引数 isContextMenu はコンテキストメニューかどうかを示す。しかし、コンテキストメニューにした場合、実際にどのような変化が現れるのかは不明。 openPopup メソッドの第6引数 attributesOverride は position 属性をメソッドの第2引数で上書きするかどうかを示す。

state プロパティ

nsIPopupBoxObject#popupState プロパティへのエイリアス。
Firefox 2 では特定のポップアップが開いているか否かという状態を調べるためには popupshowing や popuphidden などのイベントを監視して自前のフラグを管理したりする必要があったが、そういった苦労は state プロパティで一気に解消される。

state プロパティの値 意味
showing ポップアップは開く途中である。 popupshowing イベント発生時はこの状態。
open ポップアップは開いている。 popupshown イベント発生後はこの状態。
hiding ポップアップは閉じる途中である。 popuphiding イベント発生時はこの状態。
closed ポップアップは閉じている。 popuphidden イベント発生後はこの状態。

ポップアップの非同期的な動作

例えば以下のようなコードを実行すると Firefox 2 では「123」の順番で出力されるが、 Firefox 3 では「132」の順番になる。

XUL
<menupopup id="testPopup" onpopupshowing="dump('1');" onpopupshown="dump('2');"> ...
JavaScript
document.getElementById("testPopup").showPopup( ... ); dump('3');

他には?

nsIPopupBoxObject#enableKeyboardNavigator について、現段階 (Minefield 3.0a8pre) では実行すべきタイミングや得られる効果が Firefox 2 と異なる。効果が逆なのは明らかにバグなのでBug 279703のコメントに書いたところ、Bug 396517としてパッチも提供されてチェックイン間近となっている。なお、代替として ignorekeys 属性を使用することが推奨されており、こちらの動作は問題ない。

nsIPopupBoxObject#enableRollup について、現段階 (Minefield 3.0a8pre) では何もしない
代わりに、新しく追加された setConsumeRollupEvent を使うことで Windows + Minefield 3.0a8pre では期待通りの動作結果が得られた。しかし Linux + Minefield 3.0a8pre では効果がない模様?これも詳細を調べ中。

また、C++レベルでの内部的な実装が一新されたことに伴い、ポップアップの位置ズレやサイズ不正、クラッシュバグ、複数のポップアップを開いたときに閉じられなくなることがあるバグなど、多くのバグが解消されているようだ。

関連リンク

mozilla mozilla/toolkit/content/widgets/popup.xml
mozilla mozilla/layout/xul/base/public/nsIPopupBoxObject.idl
XUL:PopupGuide – MDC

TOP

カスタムツリービューの基本的な使い方(その2~追加・削除)

その1ではデータをツリー表示するだけであったが、その2ではデータを追加・削除してツリーの表示へ反映させる。

fruits.xul

まず、追加・削除するためのボタンを xul:tree 要素の上側へ配置する。

    <hbox>
        <button label="Add" oncommand="addFruit();" />
        <button label="Delete" oncommand="deleteFruits();" />
    </hbox>

fruits.js

fruits.xul へ追加した各ボタンを押下したときに呼び出される2つの関数を定義する。
addFruit 関数は新たに追加する果物の名前を入力するためのプロンプトを表示し、入力が確定すると後述の FruitsTreeView#appendItem メソッドで実際のデータ追加処理を行う。

function addFruit() {
    var name = window.prompt("Enter fruit name.", "", "Add Fruit");
    if (!name)
        return;
    gFruitsTreeView.appendItem(name);
}

deleteFruits 関数は現在選択しているツリーの行番号を後述の FruitsTreeView#selectedIndexes プロパティから取得し、各行のデータを FruitsTreeView#removeItemAt メソッドで実際に削除する。なお、行番号のズレを防ぐため、削除は下側の行から順に行う。

function deleteFruits() {
    var indexes = gFruitsTreeView.selectedIndexes;
    indexes.reverse();
    indexes.forEach(function(index) {
        gFruitsTreeView.removeItemAt(index);
    });
}

FruitsTreeView.prototype へ appendItem メソッドを追加する。
このメソッドは、引数 aName の名前を持つアイテムをデータ _data へ追加し、 nsITreeBoxObject.rowCountChanged を呼び出すことでツリー表示を更新する。
ついでに新しく追加された列を自動的に選択してフォーカスする。

    /**
     * @param String aName The name of the new item.
     */
    appendItem: function(aName) {
        this._data.push(aName);
        var newIndex = this.rowCount - 1;
        this._treeBoxObject.rowCountChanged(newIndex, 1);
        // select the new item now
        this.selection.select(newIndex);
        this._treeBoxObject.ensureRowIsVisible(newIndex);
        this._treeBoxObject.treeBody.focus();
    },

FruitsTreeView クラスへ、現在選択している行番号を配列として取得するための読み込み専用プロパティ selectedIndexes を追加する。
ツリー上の選択は nsITreeView#selection プロパティから nsITreeSelection 型オブジェクトとして取得できる。
今回ツリーは複数選択を許可しているため、選択範囲を意識する必要がある。ちなみに <tree seltype=”single”> とすれば複数選択を不許可にできる。

    /**
     * readonly property to get selected row indexes as array.
     */
    get selectedIndexes() {
        var ret = [];
        var sel = this.selection;    // nsITreeSelection
        for (var rc = 0; rc < sel.getRangeCount(); rc++) {
            var start = {}, end = {};
            sel.getRangeAt(rc, start, end);
            for (var idx = start.value; idx <= end.value; idx++) {
                ret.push(idx);
            }
        }
        return ret;
    },

FruitsTreeView.prototype へ removeItemAt メソッドを追加する。
このメソッドは、引数 aRow の行番号に対応するアイテムをデータ _data から削除し、 nsITreeBoxObject.rowCountChanged を呼び出すことでツリー表示を更新する。
ついでに現在の選択状態を解除する。

    /**
     * @param Number aIndex The row index where we want to remove.
     */
    removeItemAt: function(aIndex) {
        this._data.splice(aIndex, 1);
        this._treeBoxObject.rowCountChanged(aIndex, -1);
        this.selection.clearSelection();
    },

関連記事

TOP

カスタムツリービューの基本的な使い方(その1~表示)

何らかのデータをツリー (xul:tree 要素) で表示するためにはいくつかの方法がありますが、最も一般的なのはカスタムツリービュー方式であるかと思います。ここでは、「何らかのデータ」として最も単純な一次元の配列を想定しますが、二次元配列や何らかのオブジェクトの配列など、他のデータ構造にも応用可能です。

fruits.xul

はじめに以下のように tree 要素を配した fruits.xul を作成します。

<?xml version="1.0"?>

<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>

<page xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
      title="Fruits"
      onload="init();"
      onunload="uninit();">

    <script type="application/x-javascript" src="fruits.js" />

    <tree id="fruitsTree" flex="1">
        <treecols>
            <treecol label="Name" flex="1" primary="true" />
        </treecols>
        <treechildren flex="1" />
    </tree>

</page>

fruits.js

次に、 fruits.js を作成します。はじめに、ツリーに表示したいデータとして、果物の名前の配列 gFruitsData を定義します。配列内の null はツリー上で区切りとして表示する要素です。

var gFruitsData = [
    "Grape",
    "Apple",
    "Orange",
    "Banana",
    null,    // separator
    "Pear",
    "Peach",
    "Strawberry",
    "Cherry",
    "Melon",
    null,    // separator
    "Watermelon",
    "Plum",
    "Papaya",
    "Lemon",
];

次に、後述する FruitsTreeView クラスのインスタンスである gFruitsTreeView と、ウィンドウを開いた直後(onload イベント発生時)に呼び出される init 関数、ウィンドウを閉じる直前(onunload イベント発生時)に呼び出される uninit 関数を定義します。 init 関数では gFruitsData を引数として FruitsTreeView クラスのインスタンスを生成し、グローバル変数 gFruitsTreeView として保持します。これを xul:tree 要素の view プロパティとしてセットすることで、実際にツリーへの表示が行われます。 uninit 関数では null をセットすることで、ツリーへの表示を終了します。

var gFruitsTreeView = null;

function init() {
    gFruitsTreeView = new FruitsTreeView(gFruitsData);
    document.getElementById("fruitsTree").view = gFruitsTreeView;
}

function uninit() {
    document.getElementById("fruitsTree").view = null;
}

nsITreeView インタフェース

ここからは FruitsTreeView クラスを定義します。 FruitsTreeView クラスはコンストラクタの引数として配列形式のデータを受け取り、 _data プロパティとして内部的に参照を保持します。また、 nsITreeView.idl で定義された各プロパティ・メソッドを実装します。ただしすべてのメンバをきちんと実装する必要は無く、ツリーで実現したい機能に応じて適宜実装を加えていくことになります。 nsITreeView メンバの概要は以下の通りです。

メンバ名 概要
rowCount ツリーの行数を表す読み込み専用プロパティ。今回の場合はデータの配列の長さを返せば良い。
selection ツリー上の現在の選択を nsITreeSelection 型オブジェクトとして返す。このプロパティは自前で実装する必要は無い。
getRowProperties
getCellProperties
getColumnProperties
ツリーのセル・列・行に対してクラスを設定し、 CSS を使って見た目の詳細なカスタマイズを行う場合に使用する。今回は使用しない。
isContainer
isContainerOpen
isContainerEmpty
階層構造を有するツリーの場合に実装が必要となるが、今回は階層構造がない単純なツリーであるため使用しない。すべて false を返す。
isSeparator 引数 index の行がセパレータかどうかを表す。今回は前述したとおり、データが null の場合にセパレータとして表示したいので、その行に対応するデータの値が null なら true を返せばよい。
isSorted カラムをクリックして並び替え可能なツリーの場合に使用する。今回は常に未ソート状態なので false を返す。
canDrop ドラッグ&ドロップで移動可能なツリーの場合に使用する。現時点では必要ないので false を返す。
drop ドラッグ&ドロップで移動可能なツリーの場合に使用し、ドロップされたときに呼び出される。
getParentIndex
hasNextSibling
getLevel
これらも階層構造を有するツリーを正しく描画するために必要となる。 getParentIndex は階層構造を持たない場合は常に -1 を返さなければならない。
getImageSrc ブックーマークツリーのようにアイコンなどの画像を表示する場合に使用する。
getProgressMode セルにプログレスバーを表示するツリーを実装する場合に使用する。
getCellValue プログレスバー型のセルおよびチェックボックス型のセルで、その値を返す。
getCellText 引数で指定されたセルの表示文字列の値を返す。引数 row は行番号、 col は nsITreeColumn 型オブジェクトで、 index プロパティから列番号を取得できる。複数の列を有するツリーの場合は列番号に応じた表示文字列から取り出すが、今回は列番号は常に 0 となり、データ中の行番号に応じた要素そのものを返すだけでよい。
setTree ツリービューを xul:tree 要素の view プロパティへセットした際に呼び出される。引数 tree は nsITreeBoxObject 型オブジェクトであるが、この nsITreeBoxObject は後々利用することが多いため、 FruitsTreeView クラスの _treeBoxObject プロパティとして内部的に参照を保持しておく。
引数が null の場合、終了処理として内部的に保持していたいくつかの参照を破棄する。
toggleOpenState 階層構造を有するツリーのフォルダを開閉した時に呼び出される。
cycleHeader カラムをクリックして並び替えが可能なツリーの場合、カラムクリック時に呼び出される。
selectionChanged ツリーの選択が変更された時に何らかの処理を実行したい場合、 xul:tree 要素の onselect=”this.view.selectionChainged();” イベントハンドラをセットして呼び出すようにする?
cycleCell カラムをクリックして並び替えが可能なツリーの場合に使用する?
isEditable 引数で指定されたセルがチェックボックス型で変更可能であれば true を返す。
引数で指定されたセルをダブルクリックしてインライン編集可能であれば true を返す。
isSelectable 詳細不明。特定の行またはセルを選択不可にするためのもの?
setCellValue プログレスバー型のセルおよびチェックボックス型のセルで、その値を変更するためのメソッド。
setCellText インライン編集可能なツリー用。引数で指定されたセルの表示文字列を指定した値に変更するためのメソッド。
performAction
performActionOnRow
performActionOnCell
IDL によればツリー上で Del キーを押下すると引数 action に「delete」が渡されて呼び出されるとあるが、実際はどうやら xul:key 要素を追加するなどして自前で performActionOnRow などを呼び出す必要があるようだ。使い道がよくわからん。

FruitsTreeView クラス

今回はツリーへビューをセットしてデータ(果物の名前と区切り)を表示させるだけなので、きちんと実装する必要があるのは rowCount, isSeparator, getCellText, setTree の4つだけです。その他のメソッドは適宜 return false などにしておきます。

fruits.js
////////////////////////////////////////////////////////////////
// Custom Tree View

function FruitsTreeView(aData) {
    this._data = aData;
}

FruitsTreeView.prototype = {

    /**
     * nsITreeBoxObject
     */
    _treeBoxObject: null,

    ////////////////////////////////////////////////////////////////
    // implements nsITreeView

    get rowCount() {
        return this._data.length;
    },
    selection: null,
    getRowProperties: function(index, properties) {},
    getCellProperties: function(row, col, properties) {},
    getColumnProperties: function(col, properties) {},
    isContainer: function(index) { return false; },
    isContainerOpen: function(index) { return false; },
    isContainerEmpty: function(index) { return false; },
    isSeparator: function(index) {
        return this._data[index] == null;
    },
    isSorted: function() { return false; },
    canDrop: function(targetIndex, orientation, dataTransfer) { return false; },
    drop: function(targetIndex, orientation, dataTransfer) {},
    getParentIndex: function(rowIndex) { return -1; },
    hasNextSibling: function(rowIndex, afterIndex) { return false; },
    getLevel: function(index) { return 0; },
    getImageSrc: function(row, col) {},
    getProgressMode: function(row, col) {},
    getCellValue: function(row, col) {},
    getCellText: function(row, col) {
        switch (col.index) {
            case 0: return this._data[row];
        }
    },
    setTree: function(tree) {
        if (tree) {
            // initialize view
            this._treeBoxObject = tree;
        }
        else {
            // finalize view
            this._treeBoxObject = null;
            this._data = null;
        }
    },
    toggleOpenState: function(index) {},
    cycleHeader: function(col) {},
    selectionChanged: function() {},
    cycleCell: function(row, col) {},
    isEditable: function(row, col) { return false; },
    isSelectable: function(row, col) {},
    setCellValue: function(row, col, value) {},
    setCellText: function(row, col, value) {},
    performAction: function(action) {},
    performActionOnRow: function(action, row) {},
    performActionOnCell: function(action, row, col) {},

};

関連記事

TOP

待望の document.elementFromPoint が実装

待望の document.elementFromPoint が Firefox 3.0a8pre にて実装された。仕様は nsIDOMNSDocument.idl に詳しく書いてあるが、おおよそ以下の通りである。

  • HTML, XUL どちらの document に対しても使用可能
  • document の左上を (0, 0) とし、位置 (x, y) にある実際に見えている要素を取得する
  • 同一の document 内に存在する要素のみ取得可能。例えばインナーフレーム内の document 内に存在する要素は取得できず、代わりに iframe 要素を返す。
  • 位置 (x, y) が document の可視領域の外側にある場合、null を返す。
  • XUL document で使用する場合、例えば textbox 要素のスクロールバーのように XBL で生成された無名要素は取得できない。この場合、 textbox 要素を返す。
  • XUL document で使用する場合、 onload イベント発生以降でなければならない。

さっそく使い心地を試すべく、 HTML の document についてのサンプルをブックマークレットとして作った。使い方は、 Firefox 3 で適当なページを開き、下記のコードを丸ごとコピーしてロケーションバーへ貼り付けて移動するだけ。マウスポインタ上に位置する要素を elementFromPoint で取得してツールチップ風に表示する。

サンプル1

javascript:(function(){
    var tooltip = document.createElement("DIV");
    tooltip.style.cssText = "position: absolute; z-index: 1000; background-color: lightgreen;";
    document.body.appendChild(tooltip);
    var scanElement = function(event) {
        var elt = document.elementFromPoint(event.clientX, event.clientY);
        tooltip.innerHTML = "(" + event.clientX + ", " + event.clientY + ") " + elt.tagName;
        tooltip.style.left = (event.clientX + window.scrollX) + "px";
        tooltip.style.top  = (event.clientY + window.scrollY + 21) + "px";
    };
    document.addEventListener("mousemove", scanElement, false);
})();

しかし、上記の例はわざわざ elementFromPoint を使わなくても event.target で代用可能なので面白みがない。そこで、より機械的に指定した座標の要素を取得する例を作った。ページ左上から斜めに移動しながら要素名を取得して表示する。

サンプル2

javascript:(function(){
    var tooltip = document.createElement("DIV");
    tooltip.style.cssText = "position: absolute; z-index: 1000; background-color: lightgreen;";
    document.body.appendChild(tooltip);
    var scanElement = function(posX, posY) {
        var elt = document.elementFromPoint(posX, posY);
        tooltip.innerHTML = "(" + posX + ", " + posY + ") " + elt.tagName;
        tooltip.style.left = (posX + 2) + "px";
        tooltip.style.top  = (posY + 2) + "px";
        setTimeout(function(){ scanElement(++posX, ++posY); }, 20);
    };
    window.scrollTo(0, 0);
    scanElement(0, 0);
})();

TOP