付箋紙もどき

レモン色の小さな長方形をマウスを使ってウェブ・ページの中で動かして配置を変えることができ、長方形をクリックすると内容を書き直すことができる、そんな付箋紙の真似事をするおもちゃをウェブページの中で動かして遊んでみます。ただし、付箋紙の位置と内容を保存する機能を省いてあり、書いたものはページをリロードするだけで消えてしまいます。そのため、一過性の使い方しかできません。なお、これの元は jquery が登場するよりもずっと前の 10 年ほど前、使われ始めたばかりの prototype.js の練習で作ってから長く放置していたものです。それを jquery で書き直します。

改訂 ⇒ localStorage 版 マルチタブ非対応

使い手から見たふるまいを書き並べることから始めましょう。

  • ウェブ・ページをロードすると付箋紙を一枚配置します。
  • ウェブ・ページのボタンをクリックすると付箋紙を一枚ずつ追加できます。
  • 付箋紙は追加していった順番で上側にあるものとします。重ねると上側にある方が見えるということです。
  • 一度作った付箋紙の削除はできません。
  • 付箋紙はレモン色の表示状態と、テキスト入力状態の2つの状態をとります。
  • 普段は表示状態です。
  • 表示状態で付箋紙内をマウス・ボタンを押して、ボタンを押したままマウスを移動すると付箋紙も一緒に移動します。マウス・ボタンを離すと付箋紙の移動が終わります。
  • 付箋紙の移動開始とクリック時に、重なりの一番上にします。
  • 付箋紙の移動中と移動終了まで、ずっとレモン色の表示状態を保ちます。
  • 付箋紙をマウスでクリックすると、テキストの編集モードに切り替わります。
  • マウスのクリックは、マウス・ボタンを押した位置と離した位置が数ピクセル内で近接していれば良いことにします。ずっと押したままで離すまで時間があいても、同じ位置で押して離したらクリックしたとみなします。
  • 可能ならば、編集モードに入ると同時に編集領域を focus するようにします。うっかりミスで消去してしまわないように、既にある内容のテキストの選択はおこないません。
  • 編集モードはレモン色と同じ位置・同じ大きさの textarea をウェブ・ページに配置して、クリックした付箋紙と同じ内容を編集できるものとします。
  • 編集モードから抜け出すには、ブラウザの blur イベントを使います。ほとんどのブラウザでは、textarea の外をクリックすることで blur イベントを発生させるようになっていますが、どのような操作で blur が発生するかはブラウザ次第です。
  • 編集モードから抜け出すと、レモン色の付箋紙領域の表示に戻って textarea に打ち込んだ内容を反映させます。

詳細を詰めます。

まず、付箋紙に表示する内容から始めます。付箋紙は DOM 要素なので、その内容は HTML エスケープしたテキストにするのが無難です。一方、textarea で編集するときは HTML エスケープされてない表示されているのと同じ内容の方が良いでしょう。どちらを基本に扱うかですが、ここでは HTML エスケープされていない編集用の値を基本として、付箋紙の DOM 要素を更新するときに HTML エスケープ処理をおこなうことにします。ということで、扱うべきデータは、付箋紙の DOM 要素、textarea の DOM 要素、付箋紙ごとの textarea のための内容データの 3 種類に分かれます。付箋紙の DOM 要素に全部異なる id 属性を割りふって、内容データを付箋紙の id 属性をキーにしたオブジェクトに記憶するようにします。

付箋紙の DOM 要素は div 要素で良いでしょう。移動を mousedown イベントで始めて、mousemove イベントで位置を追跡して、mouseup イベントで移動を終われば良いだけです。div 要素はウェブ・ページ上をマウスで移動できないといけないので、body 直下の要素として position スタイルを absolute にすることにします。これで、mousemove イベントの pageX、pageY の座標系と div 要素の座標系は同じになるはずです。同様に、編集に使う textarea 要素も div 要素と同じやりかたで位置を指定したいため、やはり body 直下で同じ position スタイルを指定します。

付箋紙のクリックと移動を区別するために、mousemove イベントと click イベントを併用できれば楽ですが、それではうまく動かないブラウザも中にはあります。そこで、click イベントを使うのを止めて、mouseup イベントで条件判定をして本来なら click イベント・ハンドラに切り分ける処理を mouseup イベント処理の中に取り込むことにします。このやりかただと、マウスのボタンを押したままで大きく動かして元の位置に戻してマウスのボタンを離すと理論上はクリック扱いになります。状態変数を追加することで、いったんドラッグに入ったらクリックの判断をしないようにすることもできますが、手抜きして省いています。

一連の処理はイベント・ハンドラでおこないます。 個々の div 要素に登録してある mousedown イベント・ハンドラが処理を開始します。 まず、 要素の id を使って、 重なりの一番手前へ浮き上がらせます。 それから、 マウスボタンが押されているので、最初に div 要素内でのマウスの相対位置を計算します。この相対位置を使って、 mousemove イベント・ハンドラでマウスの位置に合わせて div 要素を動かしていきます。座標系を揃えるには、div 要素の offset() で求まる座標とそれと同じ座標系になっているマウスの pageX プロパティの値を使います。このとき、マウスボタンが離されたときにクリックされたのかどうかを調べるために downX 変数に記録を残します。mousemove イベントと mouseup イベントを受け取る準備が整ったところで、それぞれのハンドラを登録します。

mousemove イベントで、マウスの動きに合わせて付箋紙を移動します。マウスの pageX と pageY に mousedown で求めておいた相対座標を足したものが div 要素の移動先になります。なお、要素の移動には、css で left と top を変更するやりかたにしています。

mouseup イベントで、mousemove イベントと mouseup イベント・ハンドラを削ります。続いて、クリックされたかどうかをチェックします。マウスボタンが押された位置を控えてあるので、マウスボタンが離された位置と比較します。数ピクセル内ならクリックしたとみなして、textarea を使って編集できるようにします。textarea を div 要素の位置へ移動し、テキストを設定します。textarea は編集時以外に隠してあるので、show() メソッドで表示状態に切り替えます。その前に div 要素を hide() メソッドで隠しているのは、これをしておかないと textarea に focus() メソッドでフォーカスしないブラウザがあるためです。

textarea の外をクリックする等で、blur イベントが発生します。これを入力が終わったという利用者からの合図だとして、textarea の値を取り出して、div 要素による表示に戻します。

$(function(){
  var escapehtml = function(s) {
    return s.replace(/&/g, "&").replace(/\x22/g, """)
            .replace(/</g, "&lt;").replace(/>/g, "&gt;");
  };
  // values オブジェクトに付箋紙の内容テキストを保存します。
  // キーは '#bx1' のようにシャープ記号つきの付箋紙の id 属性にします。
  var values = {};
  // zorder 配列へ付箋紙の重なり順を保存します。 配列の先頭が奥、末尾が手前になります。
  var zorder = [];

  var create_leaflet = function(id, initX, initY) {
    $('body').append($('<div><\/div>').attr('id', id));
    var lastid = '#' + id;
    // あらかじめテキストが割り当ててあるならそれを使い、そうでないときは空文字列にします。
    if (cards.hasOwnProperty(lastid))
      $(lastid).html(escapehtml(values[lastid]).replace(/\n/g, "<br \/\>"));
    else
      values[lastid] = '';
    // 新しく作った付箋紙を一番手前に置きます。
    if (zorder.indexOf(lastid) == -1)
      zorder.push(lastid);
    // div 要素を初期化します。
    $(lastid)
      .css('position', 'absolute')
      .css('left', initX.toString () + 'px')
      .css('top',  initY.toString () + 'px')
      .css('z-index', (100 + zorder.indexOf(lastid)).toString ())
      .css('overflow','hidden')
      .on('mousedown',function(downev){
        // lastid と同じはずですが、念のため divid を求めます。
        var divid = '#' + $(this).attr('id');
        // 操作対象の付箋紙を一番手前に浮き上がらせます。
        zorder.splice(zorder.indexOf(divid), 1);
        zorder.push(divid);
        zorder.forEach(function (bxid, i, a) {
          $(bxid).css('z-index', (100 + i).toString());
        });
        // マウス、div 要素、textarea 要素の座標系を揃えて位置を記録します。
        var downoffset = $(this).offset();
        // mouseup イベントでクリックされたのかどうかのチェックに使います。
        var downX = downev.pageX;
        var downY = downev.pageY;
        // mousemove イベントで div 要素の移動先を計算するのに使います。
        var dx = downoffset.left - downX;
        var dy = downoffset.top  - downY;
        $(this).on('mousemove',function(moveev){
          $(this).css('left', (moveev.pageX + dx).toString () + 'px')
                 .css('top', (moveev.pageY + dy).toString () + 'px');
        });
        $(this).one('mouseup',function(upev){
          $(this).off('mousemove');
          // mousedown 時と mouseup 時のマウスの位置が近接していたらクリック。
          var rx = Math.abs(downX - upev.pageX);
          var ry = Math.abs(downY - upev.pageY);
          if ((rx < 2) && (ry < 2)) {
            // クリックのときは、textarea を div 要素の真上に移動して textarea による編集を開始します。
            // div 要素を隠して、textarea を表示してフォーカスします。
            // $(this).hide() したら offset() で位置が正しく取得できなくなるので、その前に求めておきます。
            var upoffset = $(this).offset();
            $(this).hide();
            $('#te').width($(this).width()).height($(this).height())
              .css('left', upoffset.left.toString () + 'px')
              .css('top',  upoffset.top.toString () + 'px')
              .val(values[divid])
              .show().focus()
              .one('blur',function(){
                // textarea 外のクリックを編集終了の合図とします。
                // textarea で入力したテキストをとりだして、values と div 要素に設定します。
                values[divid] = $('#te').val();
                $(divid).html(escapehtml(values[divid]).replace(/\n/g, "<br /\>"));
                $('#te').hide();
                $(divid).show();
              });
          }
          // blur イベント・ハンドラから upev イベント・オブジェクトを隠します。
          upev = null;
        });
        // mousemove、mouseup、blur イベント・ハンドラから downev イベント・オブジェクトを隠します。
        downev = null;
      });
  };

  // textarea#te 要素は最初からウェブ・ページに存在する DOM 要素です。
  // これの位置を付箋紙の初期位置に使います。
  var orgX = parseInt($('#te').offset().left);
  var orgY = parseInt($('#te').offset().top);

  var uniqid = 1;
  $('#te').hide();
  // テキストエリアに設定済みテキストを初期値としてセットします。
  values['#bx1'] = $('#te').val();
  create_leaflet('bx' + uniqid.toString(), orgX + uniqid * 4, orgY + uniqid * 4);
  uniqid += 1;

  $('#create').on('click', function(){
    create_leaflet('bx' + uniqid.toString(), orgX + uniqid * 4, orgY + uniqid * 4);
    uniqid += 1;
  });
});

ジョーク兼ネタとしての自分向けの練習問題:

この手のアプリケーションは、優れた前例がいくつもあるだけに、単純なようで作り込もうとすると大変です。以下の練習問題を列挙しながら、しみじみとそう実感しました。

1. 商品の付箋紙には様々に彩色されたものが市販されています。もどきの付箋紙でも色の異なるものを使えるようにした方が良いのかどうか、使い道の例示をしつつ論じなさい。その上で色違いの付箋紙が有用であるか不要であるかを決定しなさい。もしも、有用であると判断したならば、どのようなやりかたで色違いの付箋紙を提供するのが使い手にとって扱いやすかを論じなさい。

2. 付箋紙を模倣するアプリケーションの中には、付箋紙の大きさをマウスドラッグで変更できるようになっているものがあります。大きさの変更が必要になる場合を考察して、この機能が有用なのか不要なのかを論じなさい。

3. 付箋紙に記入できる文字のフォントのファミリーと大きさ、色を変更できるアプリケーションがあります。これらの変更が必要になる場合を考察して、この機能が有用なのか不要なのかを論じなさい。もしも、有用であると判断したならば、どのようなやりかたで文字の書式変更を提供するのが使い手にとって扱いやすかを論じなさい。

4. 永続性の記録方式で付箋紙の位置と内容等を保存できるようにすると、直前のウェブ・ページを利用していたときと、利用を再会したときでは、ウェブ・ページの大きさが変わっていることがありえます。そのようなときに、付箋紙の配置はどうするのが使い手を混乱させないかを考察しなさい。混乱を招くとしたらどのような場合があるかを例示しなさい。

5. 永続性の記憶方式で付箋紙の位置と内容等を保存するのに、使い手のブラウザごとに記録をおこなう localStorage やウェブ・サーバで記録する方法があります。どのタイミングで記録を更新するのが良いかを利用事例を述べつつ考察しなさい。例えば、ユーザが付箋紙を移動したり書き直すごとに記録を更新するのが良いか、定期的に数分置きに更新するのが良いのか、保存ボタンなどで更新するのが良いのか、ウェブ・ページを閉じるときに更新するのが良いのか。

6. 永続性の記憶をウェブ・サーバでおこなう場合は、複数の使い手が付箋紙のページを共同で利用することになるでしょう。このとき、付箋紙の移動と内容変更を無制限に許すと競合が生じることでしょう。これをどう扱うか方針を決めなさい。例えば、サーバにいったん登録したら内容の更新を許さない方針もありますし、付箋紙を作った持ち主にしか移動も内容更新も許可しないやりかたもあります。同時の内容更新を許して差分をマージするというやりかたもあります。付箋紙と持ち主を紐付けるには認証と認可が必要になるので、そのやりかたも考えること。

7. 付箋紙の変更履歴を記録する利用方法があるのかどうかを考察しなさい。あるならば、付箋紙のテキスト内容の更新履歴だけで良いのか、それとも位置や重なり関係の更新履歴も記録した方が良いのかを論じなさい。さらに、位置を記録するなら、どのタイミングの位置を記録するのが適切か、それに必要になるサーバに記録するレコード数を見積もることで方針を決定しなさい。

8. 問題 6 と 7 で決めた方針をもとに、ウェブ・サーバとクライアントの付箋紙アプリケーションのふるまいを定めて、ふるまい記述を UML ユースケース図とユースケース文書で作成しなさい。ユースケース文書には、可能な初期状況を列挙し、それぞれの状況に対する完了状況を列挙しなさい。すべての初期状況と完了状況を、一階述語論理式で記述可能なことを確かめて、不可能ならば曖昧さが残っているとしてふるまいの検討をやりなおしなさい。矛盾が見つかったときも、ふるまいの検討をやりなおしなさい。

9. 8 のふるまい記述を元に、サーバとクライアントのやりとりを、UML 協調図または UML シーケンス図で表現しなさい。

10. 9 のふるまい記述を元に、サーバとクライアントのそれぞれで扱うべきデザイン段階のデータを UML クラス図または UML オブジェクト図で表現しなさい。

11. ここまでを元に、サーバを記述するプログラミング言語とデータベース・マネジメント・システムを選定しなさい。通信量、データベースの参照頻度、データベースの更新頻度を見積ること。不変オブジェクトがどれぐらいあるか、型安全なクラスと手続きがどれぐらいあるのか、参照透明な関数がどれぐらあるかを調べること。その過程でクライアントとサーバ両方の実装段階のクラスと継承関係およびデータベースのスキーマを、 Mock を使ってユニットテスト結合テストを書きながら、プロトタイピングすること。デザインと実装に乖離があるため、多くの場合、8 まで戻ってやり直さねばならないことでしょう。

12. クライアントとサーバのユニットテスト結合テストをパスするプログラムを作成しなさい。