Autocompleterを読む その3

http://d.hatena.ne.jp/akm/20071128#1196263081
http://d.hatena.ne.jp/akm/20071203#1196683131 の続きです。

まだどんな風に表示するのか分かってないので、表示しそうな上下キーを押したときの処理から見ていきましょう。
markPrevious/markNext。

  markPrevious: function() {
    if(this.index > 0) this.index--
      else this.index = this.entryCount-1;
    this.getEntry(this.index).scrollIntoView(true);
  },
  
  markNext: function() {
    if(this.index < this.entryCount-1) this.index++
      else this.index = 0;
    this.getEntry(this.index).scrollIntoView(false);
  },

this.indexに今選んでいる要素の番号が入ってるっぽい。entryCountはおそらく選択肢の数っすよね。getEntryってなんだ?

  getEntry: function(index) {
    return this.update.firstChild.childNodes[index];
  },

おお、ただ単に引数indexを元に要素を取得するだけですね。要するにmarkPrevious/markNextはそれぞれ現在の選択を前/後に移動して、それに該当する要素をscrollIntoViewで見えるところにスクロールしてくれるっつうことっすね。


次、render。

  render: function() {
    if(this.entryCount > 0) {
      for (var i = 0; i < this.entryCount; i++)
        this.index==i ? 
          Element.addClassName(this.getEntry(i),"selected") : 
          Element.removeClassName(this.getEntry(i),"selected");
        
      if(this.hasFocus) { 
        this.show();
        this.active = true;
      }
    } else {
      this.active = false;
      this.hide();
    }
  },

entryCountが0より大きかったら、各選択肢の要素について選択されてたらクラス名selectedを付加、じゃなかったらselectedを外してますね。で、hasFocus(おそらく入力フィールドにフォーカスがあるかどうか)がtrueならshowしてactiveをtrueにします。
entryCountが0以下なら、activeをfalseにしてhideする。

じゃあrenderから呼び出されるshowはどうなってるのかってーと、

  show: function() {
    if(Element.getStyle(this.update, 'display')=='none') this.options.onShow(this.element, this.update);
    if(!this.iefix && 
      (navigator.appVersion.indexOf('MSIE')>0) &&
      (navigator.userAgent.indexOf('Opera')<0) &&
      (Element.getStyle(this.update, 'position')=='absolute')) {
      new Insertion.After(this.update, 
       '<iframe id="' + this.update.id + '_iefix" '+
       'style="display:none;position:absolute;filter:progid:DXImageTransform.Microsoft.Alpha(opacity=0);" ' +
       'src="javascript:false;" frameborder="0" scrolling="no"></iframe>');
      this.iefix = $(this.update.id+'_iefix');
    }
    if(this.iefix) setTimeout(this.fixIEOverlapping.bind(this), 50);
  },

まず、updateが非表示だったらoptions.showを呼び出します。で、IEだったら前に言っていたiefixを準備。
しかし、ここでサーバーにリクエストを送るのかと思ってたけど違うのね。ってことは、前回関係が気になっていたgetUpdatedChoicesがそれなのかな。という訳でgetUpdatedChoicesを探してみると、Autocompleter.Baseではなく、Ajax.Autocompleterに宣言されてました。

  getUpdatedChoices: function() {
    entry = encodeURIComponent(this.options.paramName) + '=' + 
      encodeURIComponent(this.getToken());

    this.options.parameters = this.options.callback ?
      this.options.callback(this.element, entry) : entry;

    if(this.options.defaultParams) 
      this.options.parameters += '&' + this.options.defaultParams;

    new Ajax.Request(this.url, this.options);
  },

パラメータを組み立てているわけですが、optionsにcallbackが設定されていたらそれを通して入力されたエントリを変換してから、options.defaultParamsがあればパラメータにそれを追加して、Ajax.Requestでリクエストを送っちゃいます。
で、こいつのレスポンスが返ってきたときはどうするのかっていうと、 new Ajax.Requestに渡されるthis.optionsのonCompleteで制御されます。これはAjax.Autocompleter#initializeで設定されているthis.onCompleteですね。

Ajax.Autocompleter = Class.create();
Object.extend(Object.extend(Ajax.Autocompleter.prototype, Autocompleter.Base.prototype), {
  initialize: function(element, update, url, options) {
    this.baseInitialize(element, update, options);
    this.options.asynchronous  = true;
    this.options.onComplete    = this.onComplete.bind(this);
    this.options.defaultParams = this.options.parameters || null;
    this.url                   = url;
  },
  onComplete: function(request) {
    this.updateChoices(request.responseText);
  }

onCompleteでは、updateChoicesにrequest.responseTextを渡してレスポンスを渡して処理してもらってますね。


updateChoicesはAutocompleter.Baseで宣言されてます。

  updateChoices: function(choices) {
    if(!this.changed && this.hasFocus) {
      this.update.innerHTML = choices;
      Element.cleanWhitespace(this.update);
      Element.cleanWhitespace(this.update.down());

      if(this.update.firstChild && this.update.down().childNodes) {
        this.entryCount = 
          this.update.down().childNodes.length;
        for (var i = 0; i < this.entryCount; i++) {
          var entry = this.getEntry(i);
          entry.autocompleteIndex = i;
          this.addObservers(entry);
        }
      } else { 
        this.entryCount = 0;
      }

      this.stopIndicator();
      this.index = 0;
      
      if(this.entryCount==1 && this.options.autoSelect) {
        this.selectEntry();
        this.hide();
      } else {
        this.render();
      }
    }
  },

choices(レスポンスで帰ってきたテキスト)で、updateの中身を更新してます。Element.cleanWhitespaceはTEXT_NODEの空白とかを消してくれるらしいっす。ちゃんと子要素があったらentryCountを設定して、各子要素にautocompleteIndexを設定、addObserversに子要素に渡しています。
子要素がなかったらentryCountに0を設定。

stopIndicatorを呼び出しているけど、今ロードしてるんだぜ、ってgifを表示するstartIndicatorで表示したものを止めるんだろうね。それは実はonKeyPressで呼び出されるonObserverEventで実行されてました。見落としてたっす。
entryCountが1でかつ、options.autoSelectが設定されてたらselectEntryを実行してhideを実行、そうじゃなかったらrenderを呼び出しています。

ここで不明なのは、addObserversぐらい?

  addObservers: function(element) {
    Event.observe(element, "mouseover", this.onHover.bindAsEventListener(this));
    Event.observe(element, "click", this.onClick.bindAsEventListener(this));
  },

mouseoverイベントをonHoverで、clickをonClickでハンドリングするように設定してますね。

  onHover: function(event) {
    var element = Event.findElement(event, 'LI');
    if(this.index != element.autocompleteIndex) 
    {
        this.index = element.autocompleteIndex;
        this.render();
    }
    Event.stop(event);
  },
  
  onClick: function(event) {
    var element = Event.findElement(event, 'LI');
    this.index = element.autocompleteIndex;
    this.selectEntry();
    this.hide();
  },

onHoverでは、イベントの発生元の要素からタグがLIの要素を見つけて、選ばれている要素のindexとその要素のautocompleteIndexでと違ってたらindexを更新して、renderで再描画。そんでイベント中止します。
onClickは、イベントの発生元の要素からタグがLIの要素を見つけて、選ばれている要素のindexとその要素のautocompleteIndexでと違ってたらindexを更新して、selectEntryを実行してhideします。

前回予想したメソッドのうち、残りはselectEntryのみ。

  getCurrentEntry: function() {
    return this.getEntry(this.index);
  },
  
  selectEntry: function() {
    this.active = false;
    this.updateElement(this.getCurrentEntry());
  },

  updateElement: function(selectedElement) {
    if (this.options.updateElement) {
      this.options.updateElement(selectedElement);
      return;
    }
    var value = '';
    if (this.options.select) {
      var nodes = document.getElementsByClassName(this.options.select, selectedElement) || [];
      if(nodes.length>0) value = Element.collectTextNodes(nodes[0], this.options.select);
    } else
      value = Element.collectTextNodesIgnoreClass(selectedElement, 'informal');
    
    var lastTokenPos = this.findLastToken();
    if (lastTokenPos != -1) {
      var newValue = this.element.value.substr(0, lastTokenPos + 1);
      var whitespace = this.element.value.substr(lastTokenPos + 1).match(/^\s+/);
      if (whitespace)
        newValue += whitespace[0];
      this.element.value = newValue + value;
    } else {
      this.element.value = value;
    }
    this.element.focus();
    
    if (this.options.afterUpdateElement)
      this.options.afterUpdateElement(this.element, selectedElement);
  },

selectEntryでは、activeをfalseに設定して、updateElementにindexから得られた選択しているエントリ(要素)を渡しています。
updateElementでは、options.updateElementが設定されていたらそれを呼び出して処理終了。
それ以外の場合は、options.selectが設定されてたら選択された要素の子要素からoptions.selectをクラス名に設定されている要素(群)を取得。見つかったら、その要素の先頭の要素に含まれるテキストノードの文字列をvalueに代入します。
options.selectが設定されてなかったら、informalというクラス名を持ってない子要素(群)のテキストを取得してvalueに設定。

valueの値にtokenが含まれていたら、それをスペースで区切ってvalueのあためにくっつけて入力フィールドのvalueに設定。じゃなかったらそのままvalueを設定します。
で、入力フィールドにフォーカスを設定して、options.afterUpdateElementが設定されていたらそれを呼び出します。


メソッドの呼び出しを大雑把にツリーでまとめるとこんな感じ。

new Ajax.Autocompleter
  Ajax.Autocompleter#initialize
    Autocompleter.Base#baseInitialize
      (派生クラス)#setOptions (もしsetOptionsがあれば)

(blurイベント発生)
  onBlur
    <250ms後>
      hide
    stopIndicator
      <表示されてたら>
        options.onHide

(keypressイベント発生)
  onKeyPress
    <activeだったら>
      <KEY_TAB or KEY_RETURN>
        selectEntry
          updateElement
            <options.updateElementがあったら>
              options.updateElement
            (入力フィールドに値を設定)
            <options.afterUpdateElement>
              options.afterUpdateElement
        (イベント中止)
      <KEY_ESC>
        hide
        (イベント中止)
      <KEY_LEFT or KEY_LEFT>
        (イベントハンドラ終了)
      <KEY_UP>
        markPrevious
          (indexを一つ前に設定)
          (選択された要素を見えるようにスクロール)
        render
          <選択肢が1つ以上あったら>
            (各要素について)
              (selectedをクラス名に追加/削除)
            <hasFocusだったら>
              show
          <選択肢が1つもなかったら>
            hide
        <Safariとかだったら>
          (イベント中止)
      <KEY_DOWN>
        markNext
          (indexを一つ後に設定)
          (選択された要素を見えるようにスクロール)
        render
          (略)
        <Safariとかだったら>
          (イベント中止)
    <activeじゃない>
      <KEY_TAB or KEY_RETURN or Safariとか or keyCodeが0>
        (イベントハンドラ終了)
    (observerをクリア)
    <options.frequency秒後>
      onObserverEvent
        getToken
        <入力された文字列がoptions.minChars以上>
          startIndicator
          getUpdatedChoices
            <options.callbackがあったら>
              options.callback
            new Ajax.Request
              <レスポンスが帰ってきたら>
                onComplete
                  updateChoices
                    (updateの中身などを更新)
                    (各選択肢について)
                      addObservers
                        (mouseoverをハンドリング)
                        (clickをハンドリング)
                    stopIndicator
                    <options.autoSelectがtrueで選択肢が一つだけの場合>
                      selectEntry
                        (略)
                      hide
                    <options.autoSelectがfalseか、選択肢が複数の場合>
                      render
        <入力された文字列がoptions.minCharsよりも短い>
          hide

(mouseoverイベント発生)
  onHover
    (indexを更新)
    render
    (イベント中止)
    
(clickイベント発生)
  onClick
    (indexを更新)
    selectEntry
    hide

これでメソッドは全部見たような気がするので、ざーっと見てみたら、

  activate: function() {
    this.changed = false;
    this.hasFocus = true;
    this.getUpdatedChoices();
  },

がどこで使われているのか分んないっす。ま、いっか。