nsITransactionManager を使ったトランザクション管理

拡張機能やXULアプリにて、ユーザの操作に対する「元に戻す」「やり直し」機能を実装する際、 nsITransactionManager が便利です。例えば Firefox 本体ではブックマークの追加/削除/移動などが nsITransactionManager によってトランザクション管理されています。

基本形

ユーザがボタンをクリックすると、金額が加算されて合計金額がテキストボックスに表示されるような、簡単なXULアプリを作ってみます。

bank.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="Bank" onload="init();">

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

    <textbox id="total" />
    <hbox>
        <button label="Deposit $1"   oncommand="deposit(1);" />
        <button label="Deposit $10"  oncommand="deposit(10);" />
        <button label="Deposit $100" oncommand="deposit(100);" />
    </hbox>

</page>

引き続き JavaScript で機能を実装します。合計金額を gTotalMoney というグローバル変数に保持し、合計金額をテキストボックスに表示するための updateUI 関数を作っておきます。 onload イベント発生時に呼び出される init 関数では、 updateUI で合計金額の初期値「$0」を表示します。
ボタンをクリックしたときに呼び出される deposit 関数では、引数に渡された金額を gTotalMoney に加算した後、 updateUI で合計金額の表示を更新します。

bank.js
var gTotalMoney = 0;

function init() {
    updateUI();
}

function updateUI() {
    document.getElementById("total").value = "$" + gTotalMoney.toString();
}

function deposit(aMoney) {
    gTotalMoney += aMoney;
    updateUI();
}

トランザクション管理

では、上記のアプリでボタンをクリックした後、その処理を元に戻す/やり直しできるようにします。

はじめに、一連のトランザクションを管理するための nsITransactionManager インスタンスであるグローバル変数 gTxnManager およびその初期化処理を追加します。

bank.js
var gTotalMoney = 0;
var gTxnManager;

function init() {
    gTxnManager = Components.classes["@mozilla.org/transactionmanager;1"].
                  createInstance(Components.interfaces.nsITransactionManager);
    updateUI();
}

トランザクション管理をして元に戻す/やり直し可能にするためには、個々の処理を nsITransaction インタフェースを実装したオブジェクトとして記述する必要があります。今回の場合は預金処理を表す DepositTxn クラスを以下のような設計で作ります。

メンバ 概要
_money コンストラクタの引数に渡された金額を内部的に保持するためのプロパティ。
doTransaction このトランザクションを実行する際に呼び出されるメソッド。
合計金額へ _money 分だけ加算する。
undoTransaction このトランザクションを元に戻す際に呼び出されるメソッド。
合計金額から _money 分だけ減算する。
redoTransaction このトランザクションをやり直しする際に呼び出されるメソッド。
通常は doTransaction と同じ処理を実行すればよい。
merge
isTransient
詳細不明。
function DepositTxn(aMoney) {
    this._money = aMoney;
}

DepositTxn.prototype = {
    doTransaction  : function() { gTotalMoney += this._money; },
    undoTransaction: function() { gTotalMoney -= this._money; },
    redoTransaction: function() this.doTransaction(),
    merge: function() false,
    get isTransient() false,
};

deposit 関数を書き換えて、合計金額を直接変更する代わりに、預金処理トランザクションクラスのインスタンスを生成して gTxnManagerdoTransaction メソッドへ渡します。こうすることで、内部的に DepositTxn インスタンスの doTransaction が呼び出されて合計金額への加算が行われ、なおかつその処理が元に戻す処理のスタックへ追加され、必要に応じて元に戻すことが可能となります。

function deposit(aMoney) {
    var txn = new DepositTxn(aMoney);
    gTxnManager.doTransaction(txn);
    updateUI();
}

次に、アプリのUIへ元に戻す/やり直しするためのボタンを追加します。

bank.xul
    <hbox>
        <button id="undoButton" label="Undo" oncommand="undo();" />
        <button id="redoButton" label="Redo" oncommand="redo();" />
    </hbox>

それぞれのボタン押下時の処理として、 nsITransactionManager の undoTransaction および redoTransaction メソッドを呼び出します。すると、元に戻す/やり直し処理のスタックの最後尾にあるトランザクション(nsITransaction インスタンス)の undoTransaction および redoTransaction メソッドが実行されます。その後、合計金額の表示を更新します。

bank.js
function undo() {
    gTxnManager.undoTransaction();
    updateUI();
}

function redo() {
    gTxnManager.redoTransaction();
    updateUI();
}

バッチ処理

例えば複数のブックマークを選択して削除した後に元に戻すと、複数のブックマークが一括して復元されます。このように、一連のトランザクションをバッチ処理として実行する場合の元に戻す/やり直しを実装します。

はじめに、アプリのUIへ複数の金額を一括して預金するためのボタンを追加します。

bank.xul
        <button label="Deposit $1+$10+$100" oncommand="depositSet([1,10,100]);" />

depositSet 関数は引数に渡された金額の配列すべてについて合計金額へ加算します。このとき、 nsITransactionManager の beginBatch メソッドを呼んでから doTransaction で個々の金額についての預金処理を実行し、最後に endBatch を呼ぶようにします。これにより、一連の預金処理がグループ化され、元に戻す際に一括して元に戻す処理が実行されます。

bank.js
function depositSet(aMoneys) {
    gTxnManager.beginBatch();
    aMoneys.forEach(function(money) {
        var txn = new DepositTxn(money);
        gTxnManager.doTransaction(txn);
    });
    gTxnManager.endBatch();
    updateUI();
}

元に戻す/やり直しスタックのリセット

nsITransactionManager の clear メソッドによって、元に戻す/やり直しスタックをいったんリセットすることができます。このメソッドを使って預金処理を確定させるボタンを作ってみます。

bank.xul
        <button id="completeButton" label="Complete" oncommand="complete();" />
bank.js
function complete() {
    gTxnManager.clear();
}

元に戻す/やり直しスタック内の個数

nsITransactionManager の numberOfUndoItems, numberOfRedoItems プロパティで元に戻す/やり直しスタック内の個数を調べることができます。これを使って元に戻す/やり直し可能なときだけボタンを押下可能にするようにしてみます。

bank.js
function updateUI() {
    document.getElementById("total").value = "$" + gTotalMoney.toString();
    var canUndo = gTxnManager.numberOfUndoItems > 0;
    var canRedo = gTxnManager.numberOfRedoItems > 0;
    function setDisabled(id, disabled) {
        var elt = document.getElementById(id);
        if (disabled)
            elt.setAttribute("disabled", "true");
        else
            elt.removeAttribute("disabled");
    }
    setDisabled("undoButton", !canUndo);
    setDisabled("redoButton", !canRedo);
    setDisabled("completeButton", !canUndo && !canRedo);
}

トランザクションの監視

nsITransactionManager の AddListener に nsITransactionListener インタフェースを実装したオブジェクトを渡して、トランザクションを監視することができます。 RemoveListener で監視を終了するのを忘れずに。

bank.xul
<page xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
      title="Bank" onload="init();" onunload="uninit();">
bank.js
function init() {
    gTxnManager = Components.classes["@mozilla.org/transactionmanager;1"].
                  createInstance(Components.interfaces.nsITransactionManager);
    gTxnManager.AddListener(gTxnListener);
    updateUI();
}

function uninit() {
    gTxnManager.RemoveListener(gTxnListener);
}

nsITransactionListener は多くのメソッドを持ち、元に戻す/やり直し処理の直前/直後などにコールバック処理を設定することができます。今回は元に戻す処理の直前に確認のダイアログを表示し、「キャンセル」ボタンを押下することで中止できるようにします。以下のように willUndo メソッドで true を返すと、処理を中止することができます。

var gTxnListener = {
    willDo: function() {},
    didDo: function() {},
    willUndo: function(aMgr, aTxn) {
        if (!window.confirm("Would you like to undo?"))
            return true;
    },
    didUndo: function() {},
    willRedo: function() {},
    didRedo: function() {},
    willBeginBatch: function() {},
    didBeginBatch: function() {},
    willEndBatch: function() {},
    didEndBatch: function() {},
    willMerge: function() {},
    didMerge: function() {},
};

TOP

TOP