In-Place-Editor (その場で編集するUI) の実装方法を考えてみた

画面上の変更したいデータや文章をクリックすると、画面遷移をせずその部分のみが編集可能状態になるユーザインターフェースのことを Edit-In-Place あるいは In-Place-Editor と呼ぶそうです。

今回は jQuery による In-Place-Editor の実装方法について考えてみます。

In-Place-Editor の効果的な使い方

登録文書を承認ワークフローでまわすようなシステムの場合、同一文書に対し複数ユーザが修正や加筆をするというケースがよくあります。その際起こる問題として「データ編集中に他者に更新を先越され、登録処理で排他エラーになってしまった」「文書上のほんの一部しか修正してないのにページのリロードにより先頭位置に戻され扱いづらい」といったものがあります。
これらの問題に対しては、「In-Place-Editor により1度に行える編集範囲を限定し、編集後 Ajax と連携し即座に DB を更新する」という手法をとることで、排他エラーやページリロードによるユーザビリティの低下を抑止することができます。

実装パターンと問題点

jQuery による In-Place-Editor の実装を考えると下記2パターンが思いつきます。

  • input / textarea 要素に対しプラグインメソッドを実行し、div / span / a 等を生成し表示を置換する
  • div / span / a 等に対しプラグインメソッドを実行し、input / textarea 要素を生成する

プラグインメソッドは、基本ページロード時に実行しますが、後者については(初期化処理における見た目上の補正処理が必要でなければ)対象要素をクリックした時に実行することもできます。

データの更新処理やデザイン上の保守性を考慮すると、input / textarea 要素を事前にマークアップできる前者の方が柔軟性があると思いますが、table 表のような大量データに対して適用しようとすると、ページロード時の初期化処理が重くなってしまうという問題が発生します。
このような場合は、クリック時に初期化処理を行える後者の方法が適してるかと思われます。

In-Place-Editor をシンプルに実装してみる

先に挙げた実装パターンを網羅した In-Place-Editor のプラグインを作ってみます。オブジェクト指向的な実装はおいといて、まずは下記の流れで、なるべくシンプルに書いてみます。

  • input 要素指定にのみに対応した実装
  • textarea 要素指定にも対応させる
  • テキスト要素( a 要素)指定にも対応させる
  • テキスト要素( a 要素)クリック時に初期化処理(プラグインの実行)を行えるようにする

基本的な動作仕様は以下のようにします。

  • 対象要素のクリックで編集モードになる
  • 編集モードでは編集の取消 / 確定ボタンが表示される
  • タブキーで取消 / 確定ボタンに移動できる
  • ESC を取消、ENTER を確定のショートカットキーとする
input 要素指定にのみに対応した実装

HTML

編集用の input 要素を記述します。

<div id="ex1" class="ex">
    <dl>
        <dt>JavaScript Library :</dt>   
        <dd>
            <input name="library" value="jQuery"/><label>latest version : </label><input name="version" value="1.3.2"/></dd>
    </dl>
</div>

CSS

編集可能要素を hover したら背景色を変えるようにします。

a.editor,
a:visited.editor{
    text-decoration:none;
    color:#3858a8;
}
a:hover.editor{
    background-color:#ffffc0;
}

JavaScript

(function($){

    $.fn.inPlaceEditor = function(){
        var self = this;
        return self.each(function(idx){
    
            //現在確定してる値
            var value;
    
            //編集フィールド
            var editor = self.eq(idx);
    
            //表示用テキストの生成
            var label = $('<a class="editor" href="javascript:void(0)"/>');
            editor.before(label).hide();
    
            //取消,確定ボタンの生成
            var cmdSet = 
                $('<span><button class="esc"">Esc</button><button \
                    class="save" >Save</button></span>');
            editor.after(cmdSet);
    
            //エディタの表示処理
            var showEditor = function(){
                editor.show().focus().select();
                cmdSet.show();
                label.hide();
            }
    
            //エディタの非表示処理
            var hideEditor = function(){
                label.show().focus();
                editor.hide();
                cmdSet.hide();
            }
    
            //取消処理
            var cancelEdit = function(){
                editor.val(value);
                hideEditor();
            }
    
            //確定処理
            var saveEdit = function(){
                value = editor.val();
                label.text(value == "" ? '(none)' : value);
                hideEditor();
            }
    
            //表示用テキストに初期値をセット
            saveEdit();
    
            //編集モードへの切替イベント
            label.bind('click',function(){
                showEditor();
            });
    
            //取消,確定のショートカットキーの割当
            editor.bind('keypress',function(evt){
                if(evt.keyCode == 27){ //ESC
                    cancelEdit();
                }
                else
                if(evt.keyCode == 13){ //ENTER
                    saveEdit();
                }
            });
    
            //取消,確定ボタンクリック時処理
            cmdSet.bind('click',function(evt){
                var element = $(evt.target);
                if(element.hasClass('esc')){
                    cancelEdit();
                }
                else
                if(element.hasClass('save')){
                    saveEdit();
                }
            });
    
        });
    }
})(jQuery);

jQuery(function($){
    //プラグインの実行
    $('#ex1 input').inPlaceEditor();
});

Demo | inplaceeditor01a.js | inplaceeditor01a.css | style.css

要素の生成、汎用処理の定義、イベントの割当てという流れで記述してます。
value 変数には編集で確定された値を常に保持しておき、input 要素にこの値を再セットすることで編集の取消しを行います。
編集が確定された場合は value 変数を書き換えると共に、テキスト要素(a 要素) に text() メソッドを使って編集内容を反映させます。html() メソッドによる書き換えも可能ですが、こちらの場合は < や & といった文字が実体参照に変換されないので注意が必要です。
イベントの割当処理では、コールバック関数に渡される event オブジェクトを参照し処理を振り分けてます。入力したキーコードを取得するには event.keyCode を参照します。イベントの起点元となった要素を取得するには event.target を参照します。

textarea 要素指定にも対応させる

まず、指定要素が textarea か否かの判定処理をします。

//TEXTAREA?
var isTextarea = (editor.attr('tagName') == 'TEXTAREA');

textarea の場合は、テキスト要素と確定/取消ボタンのコンテナを block 要素とします。こうすることでテキスト要素をクリックしやすくし、確定/取消ボタンを textareaの下部に表示させることができます。

//TEXTAREA の場合関連パーツを block 要素にする
if(isTextarea){
    label.css('display','block');
    cmdSet.css('display','block');
}

textarea の編集内容をそのままテキスト要素に反映すると改行が無視され、あまり textareaを使ってる意味がありません。ですのでここでは、改行があった場合は当該行を p 要素で囲うようにし、テキスト要素内でも改行されるようにします。
実際の処理としては、編集内容を実体参照に置換した後、split メソッドで改行単位に分割した文字列を配列に格納します。各文字列の前後に p タグを付加し、配列の連結結果を html() メソッドでテキスト要素にセットします。

//確定処理
var saveEdit = function(){
    value = editor.val();

//変更 ↓
//  label.text(value == "" ? '(none)' : value);

    //textarea の場合
    if(isTextarea){

        //実体参照に置き換える
        var html = value
            .replace(/^\n+|\n+$/g,'')
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
        var arr = html.split('\n');

        //一行を P 要素で囲う
        html = '';
        for(var i = 0 ; i < arr.length ; i++ ){
            html += ('<p>' + arr[i] + '</p>');
        }

        //html メソッドで値をセットする
        label.html(html == "" ? '(none)' : html);
    }

    //input の場合
    else{
        //text メソッドで値をセットする
        label.text(value == "" ? '(none)' : value);
    }
//変更 ↑
    hideEditor();
}

textarea 内で ENTER キーが押された場合は、編集確定処理が行われないようにします。

//変更 ↓
//  if(evt.keyCode == 13){ //ENTER
    if(!isTextarea && evt.keyCode == 13){ //ENTER
//変更 ↑

Demo | inplaceeditor01b.js

テキスト要素( a 要素)指定にも対応させる

指定要素が input / textarea でない場合は、自身を表示用のテキスト要素と判断し、編集用の input 要素を生成します。

//変更 ↓
//  //編集フィールド
//  var editor = self.eq(idx);
//
//  //TEXTAREA?
//  var isTextarea = (editor.attr('tagName') == 'TEXTAREA');
//
//  //表示用テキストの生成
//  var label = $('<a class="editor" href="javascript:void(0)"/>');
//  editor.before(label).hide();

    //編集フィールド、表示テキスト生成
    var target = self.eq(idx);
    var tagName = target.attr('tagName');

    if(/INPUT|TEXTAREA/.test(tagName)){

        //TEXTAREA?
        var isTextarea = (tagName == 'TEXTAREA');

        //編集フィールド
        var editor = target;

        //表示用テキストの生成
        var label = $('<a class="editor" href="javascript:void(0)"/>');
        editor.before(label).hide();
    }
    else{
        //表示用テキスト
        var label = target;

        //編集フィールドの生成
        var editor = $('<input/>').val(label.text());
        label.after(editor.hide());
    }
//変更 ↑

Demo | inplaceeditor01c.js

テキスト要素( a 要素)クリック時に初期化処理(プラグインの実行)を行えるようにする

この場合は、編集対象要素をクリックした時にプラグインを起動するので、初期化処理のタイミングで編集モードにする必要があります。プラグイン起動側で編集モード起動のリクエストができるように、パラメータを受け取る記述を追加します。
また、テキスト要素のクリックの都度、プラグインが実行される可能性があるので、二重起動の防止処置を追加します。

//変更 ↓
//$.fn.inPlaceEditor = function(){
$.fn.inPlaceEditor = function( config ){
//変更 ↑

    var self = this;
    return self.each(function(idx){

//追加 ↓
        //デフォルトパラメータ設定
        var c = $.extend({
            openEdit : false
        },config);
//追加 ↑
        //現在確定してる値
        var value;

        //編集フィールド、表示テキスト生成
        var target = self.eq(idx);
        var tagName = target.attr('tagName');

//追加 ↓
        //二重起動防止
        if(target.data('in-place-editor')) return ;
        target.data('in-place-editor','init');
//追加 ↑

最後に、編集モード指定による起動の場合は編集モードにします。

//起動時に編集モードにする
if(c.openEdit){
    showEditor();
}

HTML

以下のような table 表のデータ項目に In-Place-Editor を適用してみます。

<div id="ex4">
	<table>
		<tr>
			<th>name</th>
			<th>latest version</th>
			<th class="desc">Description </th>
		</tr>
		<tr>
			<td><a class="editor" href="javascript:void(0)">jQuery</a></td>
			<td><a class="editor" href="javascript:void(0)">1.3.2</a></td>
			<td><a class="editor" href="javascript:void(0)">jQuery is a lightweight JavaScript library.</a></td>
		</tr>
		<tr>
			...省略
	</table>
</div>

編集対象要素をクリックした時にプラグインを実行するようにします。

jQuery(function($){
	$('#ex4 table').bind('click',function(evt){
		var target = $(evt.target);
		if(target.hasClass('editor')){
			target.inPlaceEditor({
				openEdit : true
			});
		}
	});
});

Demo | inplaceeditor01d.js

その他の拡張機能

その他の拡張としては以下のようなものが考えられるかと思います。

  • 初期化処理時のコールバック処理(内部生成された要素の初期化処理などが可能)
  • 編集、確定、取消に対する処理の割り込みとイベントのキャンセル
  • 確定/取消ボタンの表示/非表示オプション
  • テキスト/HTML編集モードの切替オプション
  • DB更新 / Validation 処理後の(表示位置が最適化された)メッセージ表示機能

実務で使用してる実装ではこの辺の機能を取り込んで使ってます。
ソースが肥えすぎない程度にちょっとした便利機能の拡張は本体でやって、それ以外は「コールバック関数に内部生成したメソッドや要素をまとめたオブジェクトを返すからあとは好きに使ってね」という実装が柔軟性があって良いかと思います。サンプルのソースのままでは無理がありますが、その内この辺の内容についても別途エントリできたらなぁと思ってます。