付箋紙でカスタム・イベント遊び

そろそろ付箋紙のモデルがモデル・オブジェクトらしくなってきたので、 jQuery の便利機能であるカスタム・イベントで遊んでみます。 おおまかには、 コントローラがモデル・オブジェクトの内容を変更し、 内容の変化に応じたカスタム・イベントが発火します。 その結果、 カスタム・イベントのハンドラが div 要素にモデルの内容を反映するという、 処理の流れを作ります。

奥にある紙片のドラッグとクリック対応

モデル・オブジェクトを Cardboard 名付け、 cards オブジェクトと zorder 配列を閉じ込めることにします。

  const Cardboard = function(responder, val, x, y) {
    const id = this.genid(0);
    this.responder = responder;
    this.cards = {};
    this.cards[id] = {'value': val, 'left': x, 'top': y};
    this.zorder = [id];
  };

第 1 引数の responder に、 カスタム・イベントへの応答オブジェクトを指定します。 応答オブジェクトには、 便宜的に追加ボタンを流用しています。

  const handle_load = function() {
    const app = {};
    app.orgX = $(TE).offset().left;
    app.orgY = $(TE).offset().top;
    app.board = new Cardboard(IDCREATE, $(TE).val(), app.orgX, app.orgY);
    $(IDCREATE).on('click', app, handle_create_click);
    $(IDERASE).on('click', app, handle_erase_click);
    $(app.board.responder).on('card:create', handle_createcard);
    $(app.board.responder).on('card:delete', handle_deletecard);
    $(app.board.responder).on('card:rename', handle_renamecard);
    $(app.board.responder).on('card:change', handle_changecard);
    $(app.board.responder).on('card:move',   handle_movecard);
    $(app.board.responder).on('card:overwrap', handle_overwrapcards);
    if (checkStorageAvailable('localStorage')) {
      app.board.restore_data(STORAGEKEY);
      $(window).on('unload', app, handle_unload);
    }
    app.board.forEach(function(id, i) {
      $(app.board.responder).triggerHandler('card:create', [app.board, id]);
    });
    $(TE).hide();
  };

モデル・オブジェクトの中でイベントを発火するために notify メソッドを定義します。 このメソッドは、 応答オブジェクトを指定してあるときに限って triggerHandler を呼びます。 モデルのテストのとき、 応答オブジェクトを null にすることで、 DOM から切り離して単独で動かせるようにしています。

  Cardboard.prototype.notify = function(type, args) {
    if (this.responder)
      $(this.responder).triggerHandler(type, args);
  };

コントローラの mouseup イベント・ハンドラは、 付箋紙オブジェクトの位置を変更するためにモデル・オブジェクトの set_position メソッドを呼びます。 なお、 ctl オブジェクトはクロージャの代わりに div 要素移動に必要な座標を持ち歩くための構造体のようなものです。

  const handle_div_mouseup = function(ev) {
    $(this).off('mousemove');
    const ctl = ev.data;
    ctl.board.set_position(ctl.id, ctl.left, ctl.top);
    if (! ctl.isclick(ev)) {
      /* do nothing */;
    }
    else if (! ctl.board.isexposing(ctl.id)) {
      ctl.board.bring_to_front(ctl.id);
    }
    else {
      $(this).hide();
      $(TE).width($(this).width()).height($(this).height())
        .css('left', ctl.left.toString () + 'px')
        .css('top', ctl.top.toString () + 'px')
        .val(ctl.board.value(ctl.id))
        .one('blur', ctl, handle_te_blur)
        .show().focus();
    }
  };

付箋紙オブジェクトの位置をセットしたときに、 発火するのは card:move カスタム・イベントです。

  Cardboard.prototype.set_position = function(id, x, y) {
    this.cards[id].left = x;
    this.cards[id].top = y;
    this.notify('card:move', [this, id]);
  };

このカスタム・イベントのイベント・ハンドラで対応する div 要素の位置を変更します。

  const handle_movecard = function(ev, board, id) {
    $(id).css('left', board.left(id).toString() + 'px')
         .css('top',  board.top(id).toString() + 'px');
  };

続いて、 コントローラの mouseup イベント・ハンドラは、クリックした div 要素が奥に沈んでいるときは、 それを一番手前へ持ち上げるため、 モデルの bring_to_front メソッドを呼びます。

モデル・ オブジェクトは、 zorder を変更します。

  Cardboard.prototype.bring_to_front = function(id) {
    this.zorder.splice(this.zorder.indexOf(id), 1);
    this.zorder.push(id);
    this.notify('card:overwrap', [this]);
  };

zorder が変化したため、 card:overwrap カスタム・イベントを発火します。 これのイベント・ハンドラで、 全部の div 要素の z-index を書き換えます。

  const handle_overwrapcards = function(ev, board) {
    board.forEach(function(id, i) {
      $(id).css('z-index', (100 + i).toString());
    });
  };

同じカスタム・イベントを、 gc メソッドも発火します。

  Cardboard.prototype.gc = function () {
    const ncards = this.zorder.length;
    this.compute_zorder(ncards);
    this.copy_nonblank(ncards);
    this.update_zorder();
    this.notify('card:overwrap', [this]);
  };

コントローラの mouseup イベント・ハンドラに視点を戻すと、クリックした div 要素が一番手前にあるときは、 textarea を使った編集に入ります。 そして、編集が終わってコントローラの blur イベント・ハンドラが呼ばれると、 その中で、 付箋紙オブジェクトへ編集後のテキスト内容をセットします。

  const handle_te_blur = function(ev) {
    const ctl = ev.data;
    ctl.board.set_value(ctl.id, $(TE).val());
    $(TE).hide();
    $(ctl.id).show();
  };

値をセットしたときに発火するのは card:change カスタム・イベントです。

  Cardboard.prototype.set_value = function(id, value) {
    this.cards[id].value = value;
    this.notify('card:change', [this, id]);
  };

応答オブジェクトに登録してあるハンドラが実行され、 付箋紙オブジェクトに対応する div 要素のテキスト内容を変更します。

  const handle_changecard = function(ev, board, id) {
    $(id).html(escapehtml(board.value(id)).replace(/\n/g, '<br />'));
  };

付箋紙を追加するボタンを押すとイベント・ハンドラがモデル・オブジェクトへ付箋紙オブジェクトを追加するために create メソッドを呼びます。

  const handle_create_click = function(ev) {
    const app = ev.data;
    app.board.create(app.orgX, app.orgY);
  };

モデル・オブジェクトの create メソッドは card:create カスタム・イベントを発火します。

  Cardboard.prototype.create = function(orgX, orgY) {
    const seq = this.zorder.length;
    const id = this.genid(seq);
    const left = orgX + seq * 2;
    const top = orgY + seq * 2;
    this.cards[id] = {'value': '', 'left': left, 'top': top};
    this.zorder.push(id);
    this.notify('card:create', [this, id]);
  };

カスタム・イベント・ハンドラは、 id の付箋紙オブジェクトの内容で div 要素を作成します。 そのとき、 マウス・ダウン・イベントも登録します。

  const handle_createcard = function(ev, board, id) {
    $('body').append(
      $('<div></div>').attr('id', id.substr(1)).attr('class', 'bx'));
    $(id).css('position', 'absolute')
      .css('z-index', (100 + board.zidx(id)).toString())
      .on('mousedown', {'board': board}, handle_div_mousedown);
    handle_movecard(ev, board, id);
    handle_changecard(ev, board, id);
  };

空白紙片を除去するボタンを押すとイベント・ハンドラがモデル・オブジェクトの gc メソッドを呼びます。

  const handle_erase_click = function(ev) {
    const app = ev.data;
    app.board.gc();
  };

その処理中、 id の付箋紙オブジェクトを削除したときに card:delete カスタム・イベントを発火します。 また、 id が他の id へ変化するときに card:rename カスタム・イベントを発火します。

  Cardboard.prototype.copy_nonblank = function(ncards) {
    const cardsnew = {};
    for (let i = 0, j = 0; i < ncards; ++i) {
      const id = this.genid(i);
      if (this.cards[id].value.length == 0) {
        this.notify('card:delete', [id]);
      }
      else {
        const to_id = this.genid(j);
        cardsnew[to_id] = this.cards[id];
        if (id !== to_id)
          this.notify('card:rename', [id, to_id]);
        ++j;
      }
    }
    this.cards = cardsnew;
  };

これらのイベント・ハンドラは、 前者で div 要素を削除し、 後者は div 要素の id 属性を新しいものへ変更します。

  var handle_deletecard = function(ev, id) { $(id).remove(); };

  var handle_renamecard = function(ev, id, to_id) {
    // to_id は '#bx3' のような書式で、 この場合、 id 属性を 'bx3' とします。
    $(id).attr('id', to_id.substr(1));
  };