ツリーのブックマーク風アイコン表示

xul:treecell 要素へ properties=”Name” という属性を追加する、あるいはカスタムツリービューの基本的な使い方(その9~階層構造 – 表示)にあるように nsITreeView::getCellProperties でツリーセルのプロパティへ “Name” という nsIAtom を追加することで、そのツリーセルにブックマーク風のアイコン(フォルダやページ)が表示されるようになる。

というのは Firefox 3.0 までの話で、 Firefox 3.1 以上では Bug 464916 – Remove non-global rules from global tree stylesheet の影響で “Name” プロパティによるツリーのアイコン表示は不可となった。 Firefox 3.1 以上では、「chrome://browser/skin/places/places.css」を読み込ませ、 “Name” の代わりに “title” というプロパティを追加することでブックマーク風アイコン表示が可能となる。

TOP

カスタムツリービューの基本的な使い方(その10~階層構造 – フォルダ開閉)

その9~階層構造 – 表示」で作成したツリーは表示のみであったが、今回フォルダの開閉機能を実装する。

フォルダの開閉機能

フォルダの行をダブルクリックしたり、フォルダ上でEnterキーを押下したりすると、 nsITreeView#toggleOpenState メソッドが呼び出される。
toggleOpenState では、 _visibleData の中の引数 index に対応するアイテムの open プロパティを変更し、 _buildVisibleData を使って _visibleData を再構築する。
さらに、フォルダ開閉に伴い行数に変化が生じたため、 nsITreeBoxObject#rowCountChanged を呼び出す必要がある。
rowCountChanged の第1引数は変化が生じた最初の行番号、第2引数は行数の増減値である。
例えば0行目の「Red」フォルダを閉じると、そのフォルダのすぐ下の4行が消滅するため、 rowCountChanged(1, -4) となる。
これでめでたく完了、と思いきやフォルダ上でEnterキーを押した場合にフォルダの開閉状態を示す +/- 記号に変化が無いという問題があった。
そこで、 nsITreeBoxObject#invalidateRow によってその行だけを再描画する必要がある。

    toggleOpenState: function(index) {
        var lastRowCount = this.rowCount;
        // change |open| property
        this._visibleData[index].open = !this._visibleData[index].open;
        this._buildVisibleData();
        this._treeBoxObject.rowCountChanged(index + 1, this.rowCount - lastRowCount);
        // need this to update the -/+ sign when called by pressing enter key
        this._treeBoxObject.invalidateRow(index);
    },

例えば「Yellow」フォルダをダブルクリックして開いたとすると、再構築された _visibleData は下表に示すような配列となる。
item#2 の水色で着色した箇所が、フォルダを開いた際に変更された open プロパティである。
また、緑色で着色した item#9 と item#C が、フォルダを開いたことによって新たに追加されたアイテムである。

id type name parent open empty level hasNext parentIndex
[0] item#1 2 Red root true false 0 true -1
[1] item#5 1 Apple item#1 1 true 0
[2] item#6 1 Cherry item#1 1 true 0
[3] item#7 3 item#1 1 true 0
[4] item#8 1 Peach item#1 1 false 0
[5] item#2 2 Yellow root true false 0 true -1
[6] item#9 2 Citrus item#2 false false 1 true 5
[7] item#C 1 Banana item#2 1 false 5
[8] item#3 3 root 0 true -1
[9] item#4 2 Blue root false true 0 false -1

応用例~シングルクリックでのフォルダの開閉~

上記で実装したように、通常フォルダはダブルクリック時にフォルダの開閉が可能だが、ブックマークツリーのようにシングルクリックでもフォルダも開閉を可能にする。
まず、 fruits.xul の tree または treechildren 要素へ onclick 属性を追加する。

    <tree id="fruitsTree" flex="1" onclick="handleClick(event);">

先ほど onclick 属性で追加したイベントハンドラである handleTreeClick 関数を実装する。
その際、クリックした位置のアイテムを取得するために nsITreeBoxObject#getCellAt を使ってヒットテストを行う。
nsITreeBoxObjcet#getCellAt メソッドは、第1引数、第2引数で指定した座標にセルがあるかを判定し、セルがある場合は第3引数、第4引数、第5引数に引き渡したオブジェクトの value プロパティにそれぞれ行番号、列を表す nsITreeColumn オブジェクト、セル内の部位を表す文字列(””, “cell”, “text”, “image”, “twisty” のうちのいずれか)がセットされる。
今回は第1引数、第2引数にはクリックした時のマウスポインタ位置を渡して、返ってきた第3引数、第5引数の value プロパティを調べ、ツリーカラムやツリー内の余白部分などの非セル部分をクリックした場合 (row.value == -1)、フォルダ左端の+/-記号をクリックした場合 (obj.value == twisty”) を除外する。クリックした位置がツリーのセルであり、なおかつその行がフォルダである場合のみ、 nsITreeView#toggleOpenState でフォルダの開閉を行う。

////////////////////////////////////////////////////////////////
// Event Handlers

function handleClick(event) {
    if (event.button != 0)
        return;
    // hit test
    var row = {}, obj = {};
    gFruitsTreeView._treeBoxObject.getCellAt(event.clientX, event.clientY, row, {}, obj);
    if (row.value == -1 || obj.value == "twisty")
        return;
    if (gFruitsTreeView.isContainer(row.value))
        gFruitsTreeView.toggleOpenState(row.value);
}

関連記事

TOP

カスタムツリービューの基本的な使い方(その9~階層構造 – 表示)

これまで(その1~その8)は階層構造が無いフラットなツリーを取り扱ってきたが、その9ではいよいよ階層構造を有するツリーの表示を行う。
階層構造を有するツリーは、データの構造次第で実装方式も大きく変わってくるため、データの構造について熟考する必要がある。
今回のサンプルで用いるデータ構造はあくまでも一例に過ぎない。
ベースとなるソースコードはその1~表示を参照。

3種類のアイテム形式

今回ツリー上に表示するすべてのアイテムは、通常のアイテム、フォルダ、セパレータのうちのいずれかの形式となる。
これらを定数として定義しておく。これらの値は、後述の FruitItem オブジェクトの type プロパティとして使用する。

const TYPE_LEAF = 1;
const TYPE_FOLDER = 2;
const TYPE_SEPARATOR = 3;

FruitItem クラス

データに格納する個々のアイテムを表すクラスとして、下表のようなプロパティを有する FruitItem クラスを定義する。
id, type, name, parent, open プロパティはコンストラクタの引数からセットされ、 empty, level, hasNext, parentIndex プロパティは後述する FruitsTreeView#_buildVisibleData の処理内で計算してセットされる。
open, empty の2つのプロパティはフォルダ(つまり type が 2)の場合のみ使用する。

プロパティ 概要
id string 個々の FruitItem オブジェクトを一意に識別するためのID。
type number 前述の3つのタイプのうちのいずれか。
name string ツリー上で表示する文字列。
parent string 親フォルダのID。
open boolean nsITreeView#isContainerOpen 用。フォルダの開閉状態を表す。
empty boolean nsITreeView#isContainerEmpty 用。フォルダが空かどうかを表す。
level number nsITreeView#getLevel 用。ツリー上での深さ(インデントレベル)。
hasNext boolean nsITreeView#hasNextSibling 用。フォルダ内の最下部かどうかを表す。
parentIndex number nsITreeView#getParentIndex 用。親フォルダのツリー上での行番号。
/**
 * FruitItem ctor
 */
function FruitItem(aID, aType, aName, aParent, aOpen) {
    this.id     = aID;
    this.type   = aType;
    this.name   = aName;
    this.parent = aParent;
    this.open   = aOpen;
    // following four properties will be set later 
    // in the process of FruitsTreeView#_buildVisibleData
    this.empty       = null;
    this.level       = null;
    this.hasNext     = null;
    this.parentIndex = null;
}

データ

今回用いる元データは、 FruitItem オブジェクトの配列である。この配列内でのアイテムの順序はツリー上で表示されるべき順序と必ずしも一致する必要は無い。しかし、親フォルダ(parent プロパティ)が同一のアイテムの順序は実際のツリー上での表示に一致している必要がある。
つまり、今回のデータでは “root” フォルダを親とするアイテムが4つ存在するが、これらは配列内の位置が若い順番でツリー表示されることになる。

    // array of FruitItem objects
    var data = [
        new FruitItem("item#A", TYPE_FOLDER   , "Red"       , "root"  , true),
        new FruitItem("item#B", TYPE_LEAF     , "Apple"     , "item#A", null),
        new FruitItem("item#C", TYPE_LEAF     , "Cherry"    , "item#A", null),
        new FruitItem("item#D", TYPE_SEPARATOR, ""          , "item#A", null),
        new FruitItem("item#E", TYPE_LEAF     , "Peach"     , "item#A", null),
        new FruitItem("item#F", TYPE_FOLDER   , "Yellow"    , "root"  , false),
        new FruitItem("item#G", TYPE_FOLDER   , "Citrus"    , "item#F", false),
        new FruitItem("item#H", TYPE_LEAF     , "Lemon"     , "item#G", null),
        new FruitItem("item#I", TYPE_LEAF     , "Grapefruit", "item#G", null),
        new FruitItem("item#J", TYPE_LEAF     , "Banana"    , "item#F", null),
        new FruitItem("item#K", TYPE_SEPARATOR, ""          , "root"  , null),
        new FruitItem("item#L", TYPE_FOLDER   , "Blue"      , "root"  , false),
    ];

上記の元データを引数として new FruitsTreeView(data) した時点で、 gFruitsTreeView._data は下表に示すような FruitItem オブジェクトの配列となる。

id type name parent open empty level hasNext parentIndex
[0] item#A 2 Red root true
[1] item#B 1 Apple item#A
[2] item#C 1 Cherry item#A
[3] item#D 3 item#A
[4] item#E 1 Peach item#A
[5] item#F 2 Yellow root false
[6] item#G 2 Citrus item#F false
[7] item#H 1 Lemon item#G
[8] item#I 1 Grapefruit item#G
[9] item#J 1 Banana item#F
[10] item#K 3 root
[11] item#L 2 Blue root false

ツリー表示用データ _visibleData

階層構造の無いツリーでは、並び替えを行う場合を除けば、 _data として内部的に保持する配列データがすなわちツリーに表示させるデータであった。
今回の階層構造を有するツリーの場合、すべてのフォルダが開いている状態という前提であれば、 _data の配列のインデックス=ツリー上での行番号という等式が成り立つため、 _data をそのままツリー表示用データとして使用することが可能となる。
しかし、階層構造を有するツリーの場合、フォルダの開閉に伴い一部のデータがツリー上で表示されない状態も考慮しなければならない。
そこで、元データ _data とは別に、実際にツリー上で表示するアイテムだけの配列 _visibleData も内部的に保持することにする。
フォルダの開閉状態が変化するたびに _data から _visibleData を構築して、ツリー表示用データとして使用するのである。
このように _visibleData を生成する処理を FruitsTreeView クラスの _buildVisibleData メソッドとして実装する。
また、 _buildVisibleData から呼び出される副次的なメンバとして以下のプロパティやメソッドを実装する。

メンバ名 概要
_getChildItems 引数 aParent で指定したidのフォルダを親とする FruitItem オブジェクトの配列を返す。
_processChildItems FruitItem オブジェクトの配列の個々の要素に対して level, hasNext, parentIndex, open, empty の各プロパティを計算して付与しながら _visibleData を構築する。サブフォルダが存在する場合、サブフォルダ内の孫アイテムに対して再帰的に処理する。
_currentLevel _processChildItems で現在処理中のアイテムのレベルを保持する。
_parentIndex _processChildItems で現在処理中のアイテムの親フォルダの行番号を保持する。

_buildVisibleData メソッドでは、 _data 内の全オブジェクトのうち、 parent が “root” のオブジェクトを _getChildItems を使って取得し、それらについて _processChildItems で処理する。

    ////////////////////////////////////////////////////////////////
    // visible data builder

    _visibleData: [],

    _currentLevel: 0,

    _parentIndex: -1,

    _buildVisibleData: function() {
        this._visibleData = [];
        this._currentLevel = 0;
        this._parentIndex = -1;
        // process for each child of the root folder
        var childItems = this._getChildItems("root");
        this._processChildItems(childItems);
    },

    _getChildItems: function(aParent) {
        return this._data.filter(function(elt) {
            return (elt.parent == aParent);
        });
    },

    _processChildItems: function(aChildItems) {
        // process for each child
        for (var i = 0; i < aChildItems.length; i++) {
            var child = aChildItems[i];
            // compute and set |level|, |hasNext| and |parentIndex| properties
            child.level = this._currentLevel;
            child.hasNext = i < aChildItems.length - 1;
            child.parentIndex = this._parentIndex;
            var grandChildItems = null;
            // if child is a folder, compute and set |empty| properties
            if (child.type == TYPE_FOLDER) {
                grandChildItems = this._getChildItems(child.id);
                child.empty = grandChildItems.length == 0;
            }
            this._visibleData.push(child);
            // if child is an open folder, process grandchildren recursive
            if (child.type == TYPE_FOLDER && child.open) {
                var parentIndexBak = this._parentIndex;
                this._parentIndex = this._visibleData.length - 1;
                this._currentLevel++;
                this._processChildItems(grandChildItems);
                this._currentLevel--;
                this._parentIndex = parentIndexBak;
            }
        }
    },

_buildVisibleData メソッドを使って初回の _visibleData 構築を行うと、下表に示すような FruitItem オブジェクトの配列が生成される。
ピンク色で着色した部分は、 _buildVisibleData メソッドの処理によって計算され、新たに付与されたプロパティである。

今後ツリーの表示に変化が生じる何かが発生したら(例えばフォルダの開閉)、 _buildVisibleData メソッドを使って各プロパティの再計算と _visibleData の再構築を行うことになる。

id type name parent open empty level hasNext parentIndex
[0] item#A 2 Red root true false 0 true -1
[1] item#B 1 Apple item#A 1 true 0
[2] item#C 1 Cherry item#A 1 true 0
[3] item#D 3 item#A 1 true 0
[4] item#E 1 Peach item#A 1 false 0
[5] item#F 2 Yellow root false false 0 true -1
[6] item#K 3 root 0 true -1
[7] item#L 2 Blue root false true 0 false -1

nsITreeView インタフェースの実装

次に、 nsITreeView インタフェース各メンバの実装を行う。
rowCount プロパティは当然 _data ではなく _visibleData 配列の長さを返す。

    get rowCount() {
        return this._visibleData.length;
    },

今回初登場のメソッドの概要を下表に示す。

メソッド名 概要
isContainer 行番号 index の行がフォルダかどうかを返す。
isContainerOpen 行番号 index の行のフォルダの開閉状態を返す。
ある行について isContainer が true かつ isContainerEmpty が false の時、
(1) isContainerOpen が falseを返せばツリー上の左端に 記号が表示される
(2) isContainerOpen が true を返せばツリー上の左端に 記号が表示される
isContainerEmpty 行番号 index の行のフォルダ内に中身があるかどうかを返す。
getParentIndex 行番号 index の行の親となるフォルダの行番号を返す。
親が存在しないレベル0のアイテムについては-1を返すようにする。
このメソッドが正しい値を返さないと、レベル2より深いフォルダの罫線が正しく描画されない。
hasNextSibling 行番号 index の行がフォルダ内の最下部のアイテムかどうかを返す。
(1) true を返せばツリーの罫線が ├ で表示される
(2) falseを返せばツリーの罫線が └ で表示される
getLevel 引数 index の行のインデントレベルを返す。
最上位に位置するアイテムはレベル0、レベル0のフォルダ直下のアイテムはレベル1…となる。
toggleOpenState 行番号 index の行のフォルダの開閉状態を変更しようとしたときに呼び出される。
具体的にはセルをダブルクリックした時、左端の +/- 記号をクリックした時。
あるいはフォルダを選択して Enter キーや ←/→ キーを押下した時。

これら6つのメソッドを含む下記8メソッドは、いずれも _visibleData 内の対応するオブジェクトのプロパティを調べて返すだけで済む。
これらのメソッドはツリー表示時に繰り返し呼び出されるため、このように _buildVisibleData であらかじめ計算しておいてプロパティを返すだけにしておけば、パフォーマンス向上につながる。

    isContainer: function(index) {
        return this._visibleData[index].type == TYPE_FOLDER;
    },
    isContainerOpen: function(index) {
        return this._visibleData[index].open;
    },
    isContainerEmpty: function(index) {
        return this._visibleData[index].empty;
    },
    isSeparator: function(index) {
        return this._visibleData[index].type == TYPE_SEPARATOR;
    },
    getParentIndex: function(rowIndex) {
        return this._visibleData[rowIndex].parentIndex;
    },
    hasNextSibling: function(rowIndex, afterIndex) {
        return this._visibleData[rowIndex].hasNext;
    },
    getLevel: function(index) {
        return this._visibleData[index].level;
    },
    getCellText: function(row, col) {
        switch (col.index) {
            case 0: return this._visibleData[row].name;
        }
    },

setTree はこれまで通りの nsITreeBoxObject を保持する処理に加えて、 _buildVisibleData で初回の _visibleData を生成する。

    setTree: function(tree) {
        this._treeBoxObject = tree;
        this._buildVisibleData();
    },

toggleOpenState の実装は「その10~階層構造 - フォルダ開閉」の記事で別途行う予定である。したがって、現段階ではツリーは表示のみで、フォルダをダブルクリックしても何も起こらない。

    toggleOpenState: function(index) {
        alert("Not implemented yet.");
    },

アイコン表示

以上の階層構造を有するツリー表示では、せいぜいフォルダが+/-記号で表示されるくらいの簡素な見た目だが、実際のブックマークツリーなどではフォルダ型のアイコンなどが表示されている。
このようなアイコン表示を実現するためには、ブックマークツリー用のスタイルシートを読み込ませ、 nsITreeView#getCellProperties メソッドで下記のようにセルのプロパティへ "title" という値を追加する必要がある。これは xul:treecell 要素へ properties="title" 属性を追加することと等価で、 treechildren::-moz-tree-image(title) などの規則で定義されたスタイルがセルに対して適用されるようになる。

fruits.xul
<?xml-stylesheet href="chrome://browser/skin/places/places.css" type="text/css"?>
fruits.js
const ATOM_SVC = Components.classes["@mozilla.org/atom-service;1"].
                 getService(Components.interfaces.nsIAtomService);
    getCellProperties: function(row, col, properties) {
        if (col.index == 0 && this._visibleData[row].type != TYPE_SEPARATOR)
            properties.AppendElement(ATOM_SVC.getAtom("title"));
    },

関連記事

TOP

カスタムツリービューの基本的な使い方(その8~チェックボックス)

ツリーセルに文字列の代わりにチェックボックス(厳密にはチェックマーク?)を表示させることも可能である。
ベースとなるソースコードはその1~表示を参照。

fruits.xul

まず、チェックボックスの画像を表示させるためのスタイルシート fruits.css を読み込む処理命令を追加する。treecol 要素へ type=”progressmeter” 属性を付加すると、その列はプログレスバーが表示可能になる。
また、チェックボックスを直接クリックして切り替え可能にするため、 tree 要素と treecol 要素の両方へ editable=”true” 属性を追加する。

<?xml-stylesheet href="fruits.css" type="text/css"?>
    <tree id="fruitsTree" flex="1" editable="true">
        <treecols>
            <treecol label="Name" flex="1" primary="true" />
            <treecol label="Juicy" type="checkbox" editable="true" />
        </treecols>
    </tree>

fruits.css

furits.xul から読み込まれるスタイルシート fruits.css を作成する。以下のようにするとチェックマークが表示される。もちろん自前の画像を使用したり、チェックされていない状態でも画像を表示させたりすることも可能である。

treechildren::-moz-tree-checkbox(checked) {
    list-style-image: url(chrome://global/skin/checkbox/cbox-check.gif);
}

fruits.js

配列データの内容を二次元配列化する。各アイテムの0番目の要素は「Name」列で表示させる文字列、1番目の要素は「Juicy」列のチェックボックスのオン/オフ状態である。

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

これに伴い、 nsITreeView#isSeparator, getCellText を以下のように修正する。

    isSeparator: function(index) {
        return this._data[index][0] == null;
    },
    getCellText: function(row, col) {
        switch (col.index) {
            case 0: return this._data[row][0];
        }
    },

各セルのチェックボックスの現在のオン/オフ状態は nsITreeView#getCellValue で決定される。

    getCellValue: function(row, col) {
        return this._data[row][1];
    },

各セルのチェックボックスがクリックでオン/オフ切り替えが可能かどうかは、 nsITreeView#isEditable メソッドで決定される。今回の場合は、列番号が1でなおかつセパレータ行でなければ切り替え可能である。

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

チェックボックスをクリックしてオン/オフ切り替えしようとすると、 nsITreeView#setCellValue メソッドが呼び出される。このメソッドではクリックしたセルに対応するアイテムの値を変更し、なおかつ nsITreeBoxObject#invalidateCell でチェック状態が変化したセルだけを再描画する。

    setCellValue: function(row, col, value) {
        this._data[row][col.index] = value;
        this._treeBoxObject.invalidateCell(row, col);
    },

関連記事

TOP

カスタムツリービューの基本的な使い方(その7~プログレスバー)

ツリーセルに文字列の代わりにプログレスバーを表示させることも可能である。 SeaMonkey のダウンロードマネージャで実際に使われている。
ベースとなるソースコードはその1~表示を参照。

fruits.xul

まず、 treecol 要素をひとつ追加する。 treecol 要素へ type=”progressmeter” 属性を付加すると、その列はプログレスバーが表示可能になる。

            <treecol label="Name" flex="1" primary="true" />
            <treecol label="Weight" flex="1" type="progressmeter" />

fruits.js

配列データの内容を二次元配列化する。各アイテムの0番目の要素は「Name」列で表示させる文字列、1番目の要素は「Weight」列のプログレスバーで表示させる値である。

var gFruitsData = [
    ["Grape"     , 30  ],
    ["Apple"     , 50  ],
    ["Orange"    , 40  ],
    ["Banana"    , 20  ],
    [null        , null],    // separator
    ["Pear"      , 30  ],
    ["Peach"     , 40  ],
    ["Strawberry", 5   ],
    ["Cherry"    , 0   ],
    ["Melon"     , 80  ],
    [null        , null],    // separator
    ["Watermelon", 100 ],
    ["Plum"      , 20  ],
    ["Papaya"    , 70  ],
    ["Lemon"     , 10  ],
];

これに伴い、 nsITreeView#isSeparator, getCellText を以下のように修正する。

    isSeparator: function(index) {
        return this._data[index][0] == null;
    },
    getCellText: function(row, col) {
        switch (col.index) {
            case 0: return this._data[row][0];
        }
    },

プログレスバーには値をメーターの割合として表示する通常タイプ (nsITreeView#PROGRESS_NORMAL) と、値が定まらずメーターがアニメーションするような不定タイプ (nsITreeView#PROGRESS_UNDETERMINED) の2種類がある。各セルのプログレスバーがどちらのタイプであるかは nsITreeView#getProgressMode メソッドで決定される。今回の場合、列番号が1であればすべて通常のプログレスバー、それ以外は非プログレスバーなので、以下のように実装する。ただし、実際は treecol 要素が type=”progressmeter” 属性となっている列についてのみ getProgressMode が呼び出されるため、今回の場合は col.index の値は常に1である。

    getProgressMode: function(row, col) {
        switch (col.index) {
            case 1 : return Components.interfaces.nsITreeView.PROGRESS_NORMAL;
            default: return Components.interfaces.nsITreeView.PROGRESS_NONE;
        }
    },

通常タイプのプログレスバーの割合は nsITreeView#getCellValue で決定される。

    getCellValue: function(row, col) {
        return this._data[row][1];
    },

関連記事

TOP

カスタムツリービューの基本的な使い方(その6~複数列ツリー)

複数列ツリーを作成する。ベースとなるソースコードはその1~表示を参照。

fruits.xul

fruits.xul へ treecol 要素を2つ追加する。

        <treecols>
            <treecol label="Name" flex="1" primary="true" />
            <treecol label="Color" flex="1" />
            <treecol label="Season" flex="1" />
        </treecols>

fruits.js

データ構造を一次元配列から二次元配列へと拡張する。

var gFruitsData = [
    ["Grape"     , "Purple", "Summer"],
    ["Apple"     , "Red"   , "Winter"],
    ["Orange"    , "Yellow", "Always"],
    ["Banana"    , "Yellow", "Always"],
    [null        , null    , null    ],    // separator
    ["Pear"      , "Green" , "Winter"],
    ["Peach"     , "Red"   , "Summer"],
    ["Strawberry", "Red"   , "Winter"],
    ["Cherry"    , "Red"   , "Spring"],
    ["Melon"     , "Green" , "Spring"],
    [null        , null    , null    ],    // separator
    ["Watermelon", "Green" , "Summer"],
    ["Plum"      , "Purple", "Summer"],
    ["Papaya"    , "Yellow", "Always"],
    ["Lemon"     , "Yellow", "Always"],
];

nsITreeView#isSeparator, getCellText を以下のように修正する。

    isSeparator: function(index) {
        return this._data[index][0] == null;
    },
    getCellText: function(row, col) {
        switch (col.index) {
            case 0: return this._data[row][0];
            case 1: return this._data[row][1];
            case 2: return this._data[row][2];
        }
    },

応用例1~ツリーカラムを非表示にする~

複数列ツリーに限った話ではないが、ツリーカラムを非表示にするためには、以下のように tree 要素へ hidecolumnpicker 属性を追加し、各 treecol 要素の label 属性を削除した上で hideheader 属性を追加する。

    <tree id="fruitsTree" flex="1" hidecolumnpicker="true">
        <treecols>
            <treecol flex="1" hideheader="true" primary="true" />
            <treecol flex="1" hideheader="true" />
            <treecol flex="1" hideheader="true" />
        </treecols>
        <treechildren flex="1" />
    </tree>

応用例2~列の順番を変更できるようにする~

以下のように tree 要素へ enableColumnDrag 属性を追加すると、列をドラッグ&ドロップで順番を変えることが可能になる。

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

応用例3~列の幅を調整できるようにする~

以下のように treecol 要素間に splitter 要素を挿入すると、列の幅をドラッグ&ドロップで調整することが可能になる。

    <treecol label="Name" flex="1" primary="true" />
    <splitter class="tree-splitter" />
    <treecol label="Color" flex="1" />
    <splitter class="tree-splitter" />
    <treecol label="Season" flex="1" />

応用例4~列の表示状態を保存する~

以下のように各 treecol 要素へ id と persist 属性を追加すると、各列の表示/非表示(カラムヘッダ右端のピッカーから変更可能)、順序(応用例2にて変更可能)、幅(応用例3にて変更可能)といった表示状態が localstore.rdf へ保存され、次回 fruits.xul をロードしたときに自動的に復元されるようになる。

    <treecol id="fruitsName" persist="hidden ordinal width" label="Name" flex="1" primary="true" />
    <splitter class="tree-splitter" />
    <treecol id="fruitsColor" persist="hidden ordinal width" label="Color" flex="1" />
    <splitter class="tree-splitter" />
    <treecol id="fruitsSeason" persist="hidden ordinal width" label="Season" flex="1" />

関連記事

TOP

カスタムツリービューの基本的な使い方(その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

カスタムツリービューの基本的な使い方(その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

カスタムツリービューの基本的な使い方(その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