script.aculo.usを読み解く

第2回controls.js(前編)Autocompleter

今回と次回は、script.aculo.usの中でも斬新なGUI部品のライブラリが詰まっているcontrols.jsを解説します。 controls.jsには、Autocompleter(入力補完機能)と、次回解説予定のInPlaceEditor(その場で編集機能)が入っていますが、それぞれに依存関係はありませんので、回ごとに別々にお読みいただけます。

入力補完機能を加えるAutocompleter

今回は、ブラウザの入力エリアに入力補完(オートコンプリート)機能をつける、Autocompleterというライブラリのコードについて解説します。ブラウザが持っている入力補完機能は貧弱で、ユーザが過去に入力したものしか補完してくれません。検索窓については、Googleサジェストが、膨大な検索インデックスから検索頻度や人気度をもとにユーザの入力を予想し補完してくれるおかげで、ずいぶん便利になりました。

Autocompleterを使うと、そのような機能を簡単に実現できます。例えば、ユーザに人名や地名を入力してもらいたいときなど、こちらでどんな入力があるか予想がついているときに、前もって候補をリストアップして用意しておくだけで、ユーザの入力作業を強力に補助することができます。本格的にやるのであれば、Autocompleterからリアルタイムにサーバに補完候補を問い合わせる機能があるので、Googleサジェストのように、サーバ側に用意された膨大なデータベースを背景とした入力補完を提供することもできます。

Autocompleter を利用したテストページを作成してみましたので、ご覧ください。

いったいどんな仕組みで、この機能は実現されているのでしょう。例えば、fooと入力した場合何が起こるかを見ていきましょう。

Ajax.Autocompleterの方法

fooと打つと、Ajax.Autocompleterは、サーバに非同期的にアクセスして、fooではじまる言葉を問い合わせます。非同期的にアクセスするというのは、ブラウザが表ではそのままページを表示しつつ、裏でサーバに問い合わせをするということで、サーバの返答を待つ間もユーザのページ操作をリアルタイムに受けつけます。これは Ajax と呼ばれています。Ajax.Autocompleterは、hoge.cgi?value=fooとサーバのcgiにAjaxでアクセスし、サーバはこのアクセスに対して、次のようなHTMLの表現で補完候補のリストを返します。

<ul>
  <li>foobar</li>
  <li>foofoo</li>
</ul>

入力エリアの下にこのHTMLを挿入して、候補メニューとして表示します。

Autocompleter.Localの方法

Autocompleter.Localは、このサーバのcgiの動作も、ブラウザのJavaScriptでやってしまおうというわけです。そうすれば、サーバと通信する必要がありません。

そのためにまずは、全ての語を配列にして、Autocompleter.Localに渡しておきます。 ここでは、["foo","foobar","bar foobar","barfoobar"]という配列を渡したとしましょう。fooという入力にたいして、この配列のなかから補完候補としてふさわしいものを選ぶには、どんな検索をすればよいでしょうか。

まずは、もっとも単純である前方検索が使われます。

foo|
----
foo
foobar
----

デフォルトではさらに、次のような単語別前方検索も使われます。

foo|
----
foo
foobar
bar foobar
----

日本語の入力を常とする私たちには、ちょっとなじみがない補完のしかたですね。単語別前方検索では、列挙した語に含まれる単語に前方一致します。例えば"bar foobar"という語に含まれる単語のfoobarが前方一致します。この単語の区切りの判定は、正規表現の\sが使われていて、以下はマッチしません。

bar-foobar
bar,foobar
barfoobar (語がfooを含んでいるだけ)

デフォルトの前方検索と単語別前方検索の他に、オプションとして、全文検索が用意されており、これだと、列挙した語でとにかくfooを含むものが候補になります。

これらの検索アルゴリズムのコードについての説明は後述します。

キャレット位置からトークンを取り出す仕組み

ここからは、Autocompleterの心臓部の説明をしていきます。

Autocompleterの心臓部は、次のようになっています。

  • キャレットの位置を大雑把に求める
  • キャレットの位置の付近にある文字列の切りだし(この文字列をトークンといいます)
  • トークンから候補を導く

キャレットとは、入力ボックスでパカパカ点滅している、あの縦棒のことです。キャレットが何文字目にあるかを、ブラウザから取得することはできません。

キャレットの位置を大雑把に求めるのには、取得しておいた過去の内容と、現在の内容とを見比べて、内容が変わりはじめた位置の付近に、キャレットがあるという方法をとります。

この方法では、キャレットの正確な位置を求めることにはならないのですが、ここではキャレットの付近にあるトークンを求めるのが目的なので、少々の誤差は許されます(あまり勢いをつけてタイプすると、内容が変わりはじめた位置と実際のキャレットの位置との誤差が大きくなりすぎて、機能しないことがあります。実用上は問題ありません⁠⁠。

次に、このキャレットの位置の付近の、トークンを切り出します。例えば、コンマがトークン区切り文字である場合、

以前の内容 hoge,,hoge
現在の内容 hoge,foo,hoge

で、内容に変わりはじめた位置である6文字目を中心に、トークン区切り文字のコンマについて前後に検索をかけて、結果、fooがトークンとなります。

キーレスポンスを犠牲にしないための工夫

ここで、もう一点、どのようなタイミングで候補の検索が行われるか、について解説します。 単純に考えれば、キー入力のたびに検索をかけるように思えます。しかし、候補の検索は重い処理なので、それではキーレスポンスが悪くなってしまいます。

ここで、タイピングの性質について考えてみましょう。タイピングの様子をみると、タタタン、タタタタンと、連続してキーを叩く合間に、小休止があることがわかります。この小休止のときに、候補の検索をすれば、レスポンスの悪さをユーザに感じさせないようにできるわけです。

小休止を検知するために、次のように、タイマをうまく使っています。

0129: onKeyPress: function(event) {
...
0162:   if(this.observer) clearTimeout(this.observer);
0163:     this.observer = 
0164:       setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000);

this.options.frequencyはデフォルトで0.4です。

つまり、キー押下イベントのたび0.4秒待つタイマを作って、0.4秒待たずにキー押下がまたあったら、タイマを作りなおします。このタイマが発動するのは、最後のキー押下から0.4秒の間、キー押下がなかったとき、つまり、小休止のときです。そのタイミングでonObserverEventが実行されます。

このように、タイピングの性質を利用して小休止のとき検索することで、キーレスポンスを犠牲にせずに、入力補完を実現しています。

controls.js

それでは、controls.jsの前半部分のAutocompleterのコードの中身を実際に見ていきます。

0001: // script.aculo.us controls.js v1.8.1, Thu Jan 03 22:07:12 -0500 2008
0002: 
0003: // Copyright (c) 2005-2007 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
0004: //           (c) 2005-2007 Ivan Krstic (http://blogs.law.harvard.edu/ivan)
0005: //           (c) 2005-2007 Jon Tirsen (http://www.tirsen.com)
0006: // Contributors:
0007: //  Richard Livsey
0008: //  Rahul Bhargava
0009: //  Rob Wills
0010: // 
0011: // script.aculo.us is freely distributable under the terms of an MIT-style license.
0012: // For details, see the script.aculo.us web site: http://script.aculo.us/
0013: 

著作権表示です。

0014: // Autocompleter.Base handles all the autocompletion functionality 
0015: // that's independent of the data source for autocompletion. This
0016: // includes drawing the autocompletion menu, observing keyboard
0017: // and mouse events, and similar.
0018: //
0019: // Specific autocompleters need to provide, at the very least, 
0020: // a getUpdatedChoices function that will be invoked every time
0021: // the text inside the monitored textbox changes. This method 
0022: // should get the text for which to provide autocompletion by
0023: // invoking this.getToken(), NOT by directly accessing
0024: // this.element.value. This is to allow incremental tokenized
0025: // autocompletion. Specific auto-completion logic (AJAX, etc)
0026: // belongs in getUpdatedChoices.
0027: //
0028: // Tokenized incremental autocompletion is enabled automatically
0029: // when an autocompleter is instantiated with the 'tokens' option
0030: // in the options parameter, e.g.:
0031: // new Ajax.Autocompleter('id','upd', '/url/', { tokens: ',' });
0032: // will incrementally autocomplete with a comma as the token.
0033: // Additionally, ',' in the above example can be replaced with
0034: // a token array, e.g. { tokens: [',', '\n'] } which
0035: // enables autocompletion on multiple tokens. This is most 
0036: // useful when one of the tokens is \n (a newline), as it 
0037: // allows smart autocompletion after linebreaks.
0038:

このコメント文を日本語に訳すと、次のとおりです。

このAutocompleter.Baseに、入力補完の機能が集約されています。補完のデータ元に応じた動作は別になっています。 この中身は、補完メニューの描画、キーボードやマウスのイベントの監視、などです。

Autocompleterは、getUpdatedChoices関数を最低限、提供する必要があります。この関数は、監視しているテキストボックスの内容の変更のつど、呼び出されます。そのとき、補完の対象になる文字列を取り出すのにthis.getToken()を呼んでください(this.element.valueに直接アクセスしないでください⁠⁠。これでインクリメンタルなトークンによる入力補完になります。各々の補完の仕組み(Ajax,その他)は、getUpdatedChoicesに納められています。

インクリメンタルなトークンによる入力補完は、Autocompleterの生成時に'tokens'オプションを指定することで自動的に有効になります。このオプションは、例えばこのように与えます。

new Ajax.Autocompleter('id','upd', '/url/', { tokens: ',' });

さらに、上の例の ',' のところは配列にしてもよいので、例えば { tokens: [',', '\n'] } とすると、複数の区切りで補完できるようになります。区切りとして、 '\n' ⁠改行)だけは必ず入れておくのがよいでしょう。改行の前後では補完が働くようにしたほうが便利だからです。

0039: if(typeof Effect == 'undefined')
0040:   throw("controls.js requires including script.aculo.us' effects.js library");
0041: 

effect.jsがロードされているかチェックしています。

Autocompleter.Base

0042: var Autocompleter = { }
0043: Autocompleter.Base = Class.create({
0044:   baseInitialize: function(element, update, options) {
0045:     element          = $(element)
0046:     this.element     = element; 
0047:     this.update      = $(update);  
0048:     this.hasFocus    = false; 
0049:     this.changed     = false; 
0050:     this.active      = false; 
0051:     this.index       = 0;     
0052:     this.entryCount  = 0;
0053:     this.oldElementValue = this.element.value;
0054: 
0055:     if(this.setOptions)
0056:       this.setOptions(options);
0057:     else
0058:       this.options = options || { };
0059: 
0060:     this.options.paramName    = this.options.paramName || this.element.name;
0061:     this.options.tokens       = this.options.tokens || [];
0062:     this.options.frequency    = this.options.frequency || 0.4;
0063:     this.options.minChars     = this.options.minChars || 1;
0064:     this.options.onShow       = this.options.onShow || 
0065:       function(element, update){ 
0066:         if(!update.style.position || update.style.position=='absolute') {
0067:           update.style.position = 'absolute';
0068:           Position.clone(element, update, {
0069:             setHeight: false, 
0070:             offsetTop: element.offsetHeight
0071:           });
0072:         }
0073:         Effect.Appear(update,{duration:0.15});
0074:       };
0075:     this.options.onHide = this.options.onHide || 
0076:       function(element, update){ new Effect.Fade(update,{duration:0.15}) };
0077: 
0078:     if(typeof(this.options.tokens) == 'string') 
0079:       this.options.tokens = new Array(this.options.tokens);
0080:     // Force carriage returns as token delimiters anyway
0081:     if (!this.options.tokens.include('\n'))
0082:       this.options.tokens.push('\n');
0083: 
0084:     this.observer = null;
0085:     
0086:     this.element.setAttribute('autocomplete','off');
0087: 
0088:     Element.hide(this.update);
0089: 
0090:     Event.observe(this.element, 'blur', this.onBlur.bindAsEventListener(this));
0091:     Event.observe(this.element, 'keydown', this.onKeyPress.bindAsEventListener(this));
0092:   },
0093:

42~93行目はbaseInitializeです。ここでは様々な初期化が行われます。

48行目はhasFocusフラグの初期化です。 hasFocusがtrueのとき、つまり、フォーカスがあるときにだけ、候補の検索とメニューの表示が行われます。 onKeyPressイベントでtrueになり、onBlurイベントでfalseになります。

49行目はchangedフラグの初期化です。changedがtrueのとき、つまり、内容が更新されたときだけ、候補の検索が行われます。onKeyPressイベントでtrueになり、onObserverEventイベントで、改めて候補の検索が行われたとき、またfalseになります。

50行目はactiveフラグの初期化です。候補メニューを表示中かどうかを表します。activeがtrueのとき、つまりメニューの表示中には、候補選択などのキー操作が有効になります。

onBlurイベントで入力エリアがフォーカスを失ったときfalseになり、
hasFocusでフォーカスがあるときtrueになり、
候補がひとつもないときにfalseになり、
補完が行われた直後にfalseになり、
入力内容がまだ短すぎて、補完をするには不十分であるときfalseになります。

51行目はindexの初期化です。何番目の候補を内部的に選択中かを表します。候補の検索が行われた直後は0に設定され、これで、一番目の候補が内部的に選択中になります。

ユーザーが以下の入力方法で候補メニューを選択するのに応じて変わります。

  • マウスクリック
  • マウスオーバー
  • 上矢印キー、下矢印キー

52行目はentryCountの初期化です。これは候補の数を表します。候補の検索のたび、変わります。

53行目はoldElementValueの初期化です。以前の内容を保存しておき、キャレット位置の計算に使います。Ajaxが失敗したときの内容の復帰にも使います。

55行目は、optionsの初期化です。setOptionsフックがある場合は、それを呼んでやります。

60行目はoptions.paramNameの初期化です。hoge.cgi?paramName=fooの"paramName"の部分を設定します。デフォルトではAutocompleterになる要素のnameプロパティの値が使われます。

61行目はoptions.tokensの初期化です。トークン区切り文字の設定です。

62行目はoptions.frequencyの初期化です。デフォルトでは0.4秒を小休止の閾値とします。

63行目はoptions.minCharsの初期化です。デフォルトでは、入力エリアの内容が1文字以下のときは、短すぎるとして補完をしません。

65行目は、options.onShowの初期化です。候補メニューを動的に表示する関数を与えています。0.15秒でフェードインするようにしています。候補メニューのHTML要素のCSSのposition属性が設定されていない、もしくは'absolute'と設定されている場合は、Position.cloneメソッドで候補メニューの位置を調整し、入力エリアの直下に表示します。'relative'と設定されている場合は位置を調整しません。

75行目は、options.onHideの初期化です。候補メニューの表示をやめる関数を与えています。0.15秒でフェードアウトするようにしています。

81行目で、トークンの区切り文字に改行だけは必ず設定しています。

86行目で、ブラウザが持っているオートコンプリート機能が適用されないようにします。

88行目で、候補メニューを非表示にします。

90行目で、フォーカスを失ったときのイベントハンドラを設定します。

91行目で、キー押下のときのイベントハンドラを設定します。

0094: show: function() {
0095:   if(Element.getStyle(this.update, 'display')=='none') this.options.onShow(this.element, this.update);
0096:   if(!this.iefix && 
0097:     (Prototype.Browser.IE) &&
0098:     (Element.getStyle(this.update, 'position')=='absolute')) {
0099:     new Insertion.After(this.update, 
0100:      '<iframe id="' + this.update.id + '_iefix" '+
0101:      'style="display:none;position:absolute;filter:progid:DXImageTransform.Microsoft.Alpha(opacity=0);" ' +
0102:      'src="javascript:false;" frameborder="0" scrolling="no"></iframe>');
0103:     this.iefix = $(this.update.id+'_iefix');
0104:   }
0105:   if(this.iefix) setTimeout(this.fixIEOverlapping.bind(this), 50);
0106: },
0107: 
0108: fixIEOverlapping: function() {
0109:   Position.clone(this.update, this.iefix, {setTop:(!this.update.style.height)});
0110:   this.iefix.style.zIndex = 1;
0111:   this.update.style.zIndex = 2;
0112:   Element.show(this.iefix);
0113: },
0114:

94~107行目のshowメソッドは、候補メニューを表示します。

95行目で、候補メニューが非表示状態であることを確認してから、options.onShowを呼びます。

96行目以降で、IEのバグを回避しています。回避するのにfixIEOverlappingを使っています。

0115: hide: function() {
0116:   this.stopIndicator();
0117:   if(Element.getStyle(this.update, 'display')!='none') this.options.onHide(this.element, this.update);
0118:   if(this.iefix) Element.hide(this.iefix);
0119: },
0120:

115~120行目のhideメソッドは、候補メニューを非表示にします。

116行目で、インジケータの表示を止め、
117行目で、候補メニューが表示状態であることを確認してから、options.onHideを呼びます。

118行目で、IEのバグを回避しています。

0121: startIndicator: function() {
0122:   if(this.options.indicator) Element.show(this.options.indicator);
0123: },
0124:  
0125: stopIndicator: function() {
0126:   if(this.options.indicator) Element.hide(this.options.indicator);
0127: },
0128:

121~128行目のstartIndicator,stopIndicatorはそれぞれ、インジケータ インジケータ の表示と非表示をするメソッドです。

0129: onKeyPress: function(event) {
0130:   if(this.active)
0131:     switch(event.keyCode) {
0132:      case Event.KEY_TAB:
0133:      case Event.KEY_RETURN:
0134:        this.selectEntry();
0135:        Event.stop(event);
0136:      case Event.KEY_ESC:
0137:        this.hide();
0138:        this.active = false;
0139:        Event.stop(event);
0140:        return;
0141:      case Event.KEY_LEFT:
0142:      case Event.KEY_RIGHT:
0143:        return;
0144:      case Event.KEY_UP:
0145:        this.markPrevious();
0146:        this.render();
0147:        Event.stop(event);
0148:        return;
0149:      case Event.KEY_DOWN:
0150:        this.markNext();
0151:        this.render();
0152:        Event.stop(event);
0153:        return;
0154:     }
0155:    else 
0156:      if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN || 
0157:        (Prototype.Browser.WebKit > 0 && event.keyCode == 0)) return;
0158:  
0159:   this.changed = true;
0160:   this.hasFocus = true;
0161:  
0162:   if(this.observer) clearTimeout(this.observer);
0163:     this.observer = 
0164:       setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000);
0165: },
0166:

129~166行目のonKeyPressは、キー押下イベントのイベントハンドラです。

130行目で、activeフラグを見ています。これがtrueなら、候補メニューの表示中です。

131~154行目で、候補メニューの表示中は、以下の操作を受け付けます。

  • TAB,RETURNキーで候補を確定する。
  • ESCキーで候補メニューの表示をやめる。
  • KEY_LEFT, KEY_RIGHTキーはなかったこととして扱う
  • KEY_UP、KEY_DOWNキーで、候補を選択する。

159行目で、内容に更新があったことを意味するchangedフラグを立てています。

160行目で、フォーカスがあることを意味するhasFocusフラグを立てています。

162~164行目で、タイピングの小休止をタイマをうまく使って検知しています。上記の解説を参照してください。

0167: activate: function() {
0168:   this.changed = false;
0169:   this.hasFocus = true;
0170:   this.getUpdatedChoices();
0171: },
0172:

167~172行目はactivateです。この関数は現在使われていません。

0173: onHover: function(event) {
0174:   var element = Event.findElement(event, 'LI');
0175:   if(this.index != element.autocompleteIndex) 
0176:   {
0177:       this.index = element.autocompleteIndex;
0178:       this.render();
0179:   }
0180:   Event.stop(event);
0181: },
0182:

173行目~182行目のonHoverは、マウスカーソルが、候補メニューの要素の上にきたときのイベントハンドラです。後述する297行目のaddObserversで使われます。

174行目で、イベントの発生源であるカーソル下の候補メニューの要素を特定し、

177行目で、カーソル下の候補を、内部的に選択中にしています。

178行目で、候補メニューを再表示しています。

0183: onClick: function(event) {
0184:   var element = Event.findElement(event, 'LI');
0185:   this.index = element.autocompleteIndex;
0186:   this.selectEntry();
0187:   this.hide();
0188: },
0189:

183~189行目のonClickは、候補メニューがマウスクリックされたときのイベントハンドラです。

184行目でイベントの発生源に一番近い<li>要素を求めます。それが選択された候補です。

185行目で内部的に選択中にしてから、
186行目で確定します。

187行目で候補メニューを非表示にします。

0190: onBlur: function(event) {
0191:   // needed to make click events working
0192:   setTimeout(this.hide.bind(this), 250);
0193:   this.hasFocus = false;
0194:   this.active = false;     
0195: }, 
0196:

190~196行目のonBlurは、入力エリアがフォーカスを失ったときのイベントハンドラです。クリックイベントの混乱を避けるため、hide()する前に0.25秒の遅延をとっています。

193行目でhasFocusをfalseにして、フォーカスを失った状態であることを示しています。

194行目でactiveをfalseにして、メニューの表示をやめます。

0197: render: function() {
0198:   if(this.entryCount > 0) {
0199:     for (var i = 0; i < this.entryCount; i++)
0200:       this.index==i ? 
0201:         Element.addClassName(this.getEntry(i),"selected") : 
0202:     Element.removeClassName(this.getEntry(i),"selected");
0203:     if(this.hasFocus) { 
0204:       this.show();
0205:       this.active = true;
0206:     }
0207:   } else {
0208:     this.active = false;
0209:     this.hide();
0210:   }
0211: },
0212:

197~212行目のrenderは、候補メニューを描画する関数です。

201行目で、候補の中でも特に内部的に選択中のものには、class属性に"selected"をつけます。

203行目で、フォーカスがあれば、候補メニューを表示し、メニュー表示中を意味するactiveフラグを立てます。

207行目で、候補がひとつもなければ、候補メニューの表示をやめます。

0213: markPrevious: function() {
0214:   if(this.index > 0) this.index--
0215:     else this.index = this.entryCount-1;
0216:   this.getEntry(this.index).scrollIntoView(true);
0217: },
0218: 
0219: markNext: function() {
0220:   if(this.index < this.entryCount-1) this.index++
0221:     else this.index = 0;
0222:   this.getEntry(this.index).scrollIntoView(false);
0223: },
0224:

213~24行目のmarkPreviousとmarkNextは、それぞれ、上下カーソルキーで、候補の選択をするときに呼ばれる関数です。

this.indexは内部的に"何番めの候補を選択中か"を表します。

216行目と222行目で、それぞれ、内部的に選択中の候補が見えるようにスクロールします。

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

225~228行目のgetEntryは、引数で与えられたindex番目の候補のDOM要素を返す関数です。

0229: getCurrentEntry: function() {
0230:   return this.getEntry(this.index);
0231: },  
0232:

229~232行目のgetCurrentEntryは、内部的に選択中の候補のDOM要素を返す関数です。

0233: selectEntry: function() {
0234:   this.active = false;
0235:   this.updateElement(this.getCurrentEntry());
0236: },
0237:

233~237行目のselectEntryは、候補を確定する関数です。

234行目で、候補メニューを非表示にするフラグを立てています。

235行目で、実際に入力エリアの内容を書き換えて補完するupdateElement関数を呼んでいます。

0238: updateElement: function(selectedElement) {
0239:   if (this.options.updateElement) {
0240:     this.options.updateElement(selectedElement);
0241:     return;
0242:   }
0243:   var value = '';
0244:   if (this.options.select) {
0245:     var nodes = $(selectedElement).select('.' + this.options.select) || [];
0246:     if(nodes.length>0) value = Element.collectTextNodes(nodes[0], this.options.select);
0247:   } else
0248:     value = Element.collectTextNodesIgnoreClass(selectedElement, 'informal');
0249:

238~266行目のupdateElementは、候補を確定したときに入力エリアの内容を書きかえる関数です。

239行目で、更新前のフックthis.options.updateElementが用意されています。

243~249行目で、<ul>や<li>のDOMツリーをたどって文字列を取り出すために、effect.js内のElement.collectTextNodeやElement.collectTextNodesIgnoreClassが使われます。

このとき、this.options.selectというオプションが関わってきます。このオプションを例えば"selectme"とすると、

245行目で、Element.selectメソッドを'.selectme'を引数にして呼び、そのCSSセレクタに一致するノードを集めます。

246行目で、Element.collectTextNodeによって、クラスが"selectme"のノードの文字列を集めます。例えば、以下の候補を確定したときfoobarを出力します。

<li>
  無駄無駄
  <span class="selectme">foobar</span>
</li>

248行目で、このオプションが設定されていないとき、Element.collectTextNodesIgnoreClassによって、クラスが"informal"のノードの文字列を無視します。つまり、以下の候補を確定したときfoobarを出力します。

<li>
  <span class="informal">無視無視</span>
  foobar
</li>
0250:   var bounds = this.getTokenBounds();
0251:   if (bounds[0] != -1) {
0252:     var newValue = this.element.value.substr(0, bounds[0]);
0253:     var whitespace = this.element.value.substr(bounds[0]).match(/^\s+/);
0254:     if (whitespace)
0255:       newValue += whitespace[0];
0256:     this.element.value = newValue + value + this.element.value.substr(bounds[1]);
0257:   } else {
0258:     this.element.value = value;
0259:   }
0260:   this.oldElementValue = this.element.value;
0261:   this.element.focus();
0262: 
0263:   if (this.options.afterUpdateElement)
0264:     this.options.afterUpdateElement(this.element, selectedElement);
0265: },
0266:

250行目までで、候補のテキストを取り出せたので、次にそれを入力エリアに挿入します。

250行目で、getTokenBounds関数で、候補を挿入する位置を調べます。この関数は、トークンの範囲を[始点,終点]の配列で返してきます。

251~259行目で、ここでbounds[0]==-1であれば、入力エリアが空ということなので、単純に上書きをします。そうでなければ、入力エリアが空ではないということなので、入力エリアの既存の内容にテキストを挿入します。

253~255行目で、このトークンの範囲は、前方に空白を含んで計算されているので、その分を補ってやります。

260行目で、oldElementValueを入力エリアの内容に更新します。

261行目で、フォーカスを入力エリアにあたえます。

263行目で、更新後のフックthis.options.afterUpdateElementが用意されています。

0267: updateChoices: function(choices) {
0268:   if(!this.changed && this.hasFocus) {
0269:     this.update.innerHTML = choices;
0270:     Element.cleanWhitespace(this.update);
0271:     Element.cleanWhitespace(this.update.down());
0272:  
0273:     if(this.update.firstChild && this.update.down().childNodes) {
0274:       this.entryCount = 
0275:         this.update.down().childNodes.length;
0276:       for (var i = 0; i < this.entryCount; i++) {
0277:         var entry = this.getEntry(i);
0278:         entry.autocompleteIndex = i;
0279:         this.addObservers(entry);
0280:       }
0281:     } else { 
0282:       this.entryCount = 0;
0283:     }
0284:  
0285:     this.stopIndicator();
0286:     this.index = 0;
0287:   
0288:     if(this.entryCount==1 && this.options.autoSelect) {
0289:       this.selectEntry();
0290:       this.hide();
0291:     } else {
0292:       this.render();
0293:     }
0294:   }
0295: },
0296:

267~296行目のupdateChoicesは、choicesという引数で候補のリストのHTMLを受け取って、処理する関数です。

例えば、ここでchoicesは以下のようなものです。

<ul>
  <li>
    foo
  </li>
  <li>
    <span class="informal">無視無視</span>
    foobar
  </li>
</ul>

270行目と271行目で、this.updateやその子ノードの<ul>...から、無駄な空白を取り除いていますElement.downメソッドは、子孫ノードを返します⁠⁠。

278、279行目で、各<li>要素に、番号を振り、イベントハンドラを設定しています。

285行目で、インジケータを非表示にします。

286行目で、先頭の候補を内部的な選択中にします。

288行目で、候補がひとつだけで、かつ、options.autoSelectが設定されているときは、自動で確定します。

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

297~301行目のaddObserversは、候補メニューのDOM要素に、カーソルが入ったとき(onHover)と、クリックされたとき(onClick)の、イベントハンドラを設定しています。

0302: onObserverEvent: function() {
0303:   this.changed = false;   
0304:   this.tokenBounds = null;
0305:   if(this.getToken().length>=this.options.minChars) {
0306:     this.getUpdatedChoices();
0307:   } else {
0308:     this.active = false;
0309:     this.hide();
0310:   }
0311:   this.oldElementValue = this.element.value;
0312: },
0313:

302~313行目は、onObserverEvent関数です。これはAutocompleterの心臓部です。キャレットのおおまかな位置を求めて、トークンを切り出して、トークンから候補を導きます。

304行目で、tokenBoundsをnullにしてからgetTokenを呼ぶことで、改めて、キャレットの位置の計算とトークンの切りだしが行われます(getTokenは、無駄な処理を減らすために、tokenBoundsの結果が残っているときはそれをそのまま返すようになっています⁠⁠。

305行目で、切り出したトークンの長さがminCharsを満たすときだけ、候補の検索を行っています。

306行目で、getUpdatedChoicesを呼んでいます。この関数は、Ajax.Autocompleterでは、サーバへの問い合わせを、Autocompleter.Localでは、ローカルに与えられた語の配列からの検索を、それぞれ行います。

308、309行目で、minCharsに満たなかったときは、候補を非表示にしています。

311行目で、入力エリアの内容を保存しておきます。これがあとで、キャレットの位置を求めたり、Ajaxが失敗したときに内容を復帰するのに使われます。

0314: getToken: function() {
0315:   var bounds = this.getTokenBounds();
0316:   return this.element.value.substring(bounds[0], bounds[1]).strip();
0317: },
0318:

314~318行目のgetTokenは、トークン範囲を計算し、そこからトークンを切り出して返します。

0319: getTokenBounds: function() {
0320:   if (null != this.tokenBounds) return this.tokenBounds;
0321:   var value = this.element.value;
0322:   if (value.strip().empty()) return [-1, 0];
0323:   var diff = arguments.callee.getFirstDifferencePos(value, this.oldElementValue);
0324:   var offset = (diff == this.oldElementValue.length ? 1 : 0);
0325:   var prevTokenPos = -1, nextTokenPos = value.length;
0326:   var tp;
0327:   for (var index = 0, l = this.options.tokens.length; index < l; ++index) {
0328:     tp = value.lastIndexOf(this.options.tokens[index], diff + offset - 1);
0329:     if (tp > prevTokenPos) prevTokenPos = tp;
0330:     tp = value.indexOf(this.options.tokens[index], diff + offset);
0331:     if (-1 != tp && tp < nextTokenPos) nextTokenPos = tp;
0332:   }
0333:   return (this.tokenBounds = [prevTokenPos + 1, nextTokenPos]);
0334:}
0335:});
0336:

319~336行目のgetTokenBoundsを見ていきましょう。これは、キャレットの位置をおおまかに求めて、切り出すべきトークンの範囲を返す関数です。

返り値は[a,b]の形で、aはトークンの範囲の始点、bはその終点を表す整数です。入力エリアの内容が空かスペースだけのときはただちに[-1,0]を返します。

options.tokensは、トークンの区切り文字が入っている配列です。デフォルトでは'\n'が入っています。

320行目では、次のようになっていて、無駄な処理を省くようになっています。

if (前回の結果を消さずにこの関数を呼び出した場合) return 前回の内容;

322行目では、次のようになっています。

if (入力エリアの内容が空かスペースだけの場合) return [-1,0];

323行目では、getFirstDifferencePosで、以前の内容と、現在の内容を見比べて、内容が変わりはじめた位置を求めています。

324~332行目では、トークンの区切り文字を全て試して、区切りの幅がもっとも短くなるように範囲を計算しています。

0337:  Autocompleter.Base.prototype.getTokenBounds.getFirstDifferencePos = function(newS, oldS) {
0338:   var boundary = Math.min(newS.length, oldS.length);
0339:   for (var index = 0; index < boundary; ++index)
0340:     if (newS[index] != oldS[index])
0341:       return index;
0342:     return boundary;
0343:  };
0344:

337~344行目のgetFirstDifferencePosは、以前の内容と、現在の内容を見比べて、内容が変わりはじめた位置を返します。

以上でAutocompleter.Baseは終わりです。次に、Ajaxでサーバに候補を問い合わせるAjax.Autocompleterについて解説します。

Ajax.Autocompleter


0345: Ajax.Autocompleter = Class.create(Autocompleter.Base, {
0346:   initialize: function(element, update, url, options) {
0347:     this.baseInitialize(element, update, options);
0348:     this.options.asynchronous  = true;
0349:     this.options.onComplete    = this.onComplete.bind(this);
0350:     this.options.defaultParams = this.options.parameters || null;
0351:     this.url                   = url;
0352:   },
0353:

345~353行目は、initializeで、通常の初期化のほか、Ajax.Autocompleter特有のオプションを追加します。

348行目のoptions.asynchronousは、Ajaxを非同期で行うオプションです。

349行目のoptions.onCompleteは、Ajaxの完了時にコールバックする関数のオプションです。

350行目のoptions.defaultParamsは、追加するクエリパラメータのオプションです。

351行目のurlは、問い合わせるサーバのURLです。

0354: getUpdatedChoices: function() {
0355:   this.startIndicator();
0356:   
0357:   var entry = encodeURIComponent(this.options.paramName) + '=' + 
0358:     encodeURIComponent(this.getToken());
0359:  
0360:   this.options.parameters = this.options.callback ?
0361:     this.options.callback(this.element, entry) : entry;
0362:  
0363:   if(this.options.defaultParams) 
0364:     this.options.parameters += '&' + this.options.defaultParams;
0365:   
0366:   new Ajax.Request(this.url, this.options);
0367: },
0368:

354~368行目のgetUpdatedChoicesは、トークンを得て、Ajaxでサーバに候補を問い合わせる関数です。

355行目でインジケータを表示し、
357行目でAjaxクエリパラメータを、まずはparamName=fooと作ります。

360行目でライブラリの利用者がクエリパラメータを自由に編集できるように、options.callbackに入力エリアとクエリパラメータを渡しています。

364行目でクエリパラメータにdefaultParamsをさらに追加します。

366行目でAjaxで候補を問い合わせます。

0369: onComplete: function(request) {
0370:   this.updateChoices(request.responseText);
0371: }
0372: });
0373:

369~373行目のonCompleteは、Ajaxの完了時にサーバからの返答を処理する関数です。

Autocompleter.Local

0374: // The local array autocompleter. Used when you'd prefer to
0375: // inject an array of autocompletion options into the page, rather
0376: // than sending out Ajax queries, which can be quite slow sometimes.
0377: //
0378: // The constructor takes four parameters. The first two are, as usual,
0379: // the id of the monitored textbox, and id of the autocompletion menu.
0380: // The third is the array you want to autocomplete from, and the fourth
0381: // is the options block.
0382: //
0383: // Extra local autocompletion options:
0384: // - choices - How many autocompletion choices to offer
0385: //
0386: // - partialSearch - If false, the autocompleter will match entered
0387: //                    text only at the beginning of strings in the 
0388: //                    autocomplete array. Defaults to true, which will
0389: //                    match text at the beginning of any *word* in the
0390: //                    strings in the autocomplete array. If you want to
0391: //                    search anywhere in the string, additionally set
0392: //                    the option fullSearch to true (default: off).
0393: //
0394: // - fullSsearch - Search anywhere in autocomplete array strings.
0395: //
0396: // - partialChars - How many characters to enter before triggering
0397: //                   a partial match (unlike minChars, which defines
0398: //                   how many characters are required to do any match
0399: //                   at all). Defaults to 2.
0400: //
0401: // - ignoreCase - Whether to ignore case when autocompleting.
0402: //                 Defaults to true.
0403: //
0404: // It's possible to pass in a custom function as the 'selector' 
0405: // option, if you prefer to write your own autocompletion logic.
0406: // In that case, the other options above will not apply unless
0407: // you support them.

このコメント文を日本語に訳すと以下のとおりです。

ローカルな配列の入力補完。補完したい語の配列をページに埋め込むことで、いちいちサーバにAjaxで問い合わせずに済むようになります(Ajaxは場合によっては、かなり遅いことがあるからです⁠⁠。 コンストラクタは4つの引数をとります。最初の2つは、いつもどおり、監視するテキストボックスのidと、候補メニューを表示するメニューのidです。3番めは、補完したい語の配列、4番めは、オプションです。

ローカル入力補完に特有のオプション:

- choices -
候補をいくつまで示すか。
- partialSearch -
もしfalseなら、語の配列にある文字列の中で、入力されたテキストと前方一致するものだけが、候補になります。デフォルトではtrueで、この場合、配列の語の文字列について、文字列中の単語ごとに前方一致が探されます。もし、文字列の全体について探したければ、さらにfullSearchオプションをtrueにしてください(デフォルトではoff⁠⁠。
- fullSearch -
配列の語の文字列の全体を検索します。
- partialChars -
単語別一致検索が動くのに必要な入力の文字数(minCharsと違って、検索の方法に関わらず何文字必要かです⁠⁠。デフォルトは2。
- ignoreCase -
大文字小文字を無視して補完するかどうか。デフォルトはtrue。
カスタマイズした関数を'selector'オプションに渡すことで、自前の補完ロジックを使うこともできます。 その場合は、上記のオプションは扱われません(あなたがサポートすれば別ですが)。
0409: Autocompleter.Local = Class.create(Autocompleter.Base, {
0410:   initialize: function(element, update, array, options) {
0411:     this.baseInitialize(element, update, options);
0412:     this.options.array = array;
0413:   },
0414:

410~414行目は、initializeです。3番めの引数で補完したい語の配列をとります。

0415: getUpdatedChoices: function() {
0416:   this.updateChoices(this.options.selector(this));
0417: },
0418:

415~418行目は、getUpdatedChoicesです。this.options.selectorが、候補リストのHTML表現を返し、updateChoicesが、それをメニューに表示します。

0419: setOptions: function(options) {
0420:   this.options = Object.extend({
0421:     choices: 10,
0422:     partialSearch: true,
0423:     partialChars: 2,
0424:     ignoreCase: true,
0425:     fullSearch: false,
0426:     selector: function(instance) {
0427:       var ret       = []; // Beginning matches
0428:       var partial   = []; // Inside matches
0429:       var entry     = instance.getToken();
0430:       var count     = 0;
0431:  
0432:       for (var i = 0; i < instance.options.array.length &&  
0433:         ret.length < instance.options.choices ; i++) { 
0434:  
0435:         var elem = instance.options.array[i];
0436:         var foundPos = instance.options.ignoreCase ? 
0437:           elem.toLowerCase().indexOf(entry.toLowerCase()) : 
0438:           elem.indexOf(entry);
0439:  
0440:         while (foundPos != -1) {
0441:           if (foundPos == 0 && elem.length != entry.length) { 
0442:             ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" + 
0443:               elem.substr(entry.length) + "</li>");
0444:             break;
0445:           } else if (entry.length >= instance.options.partialChars && 
0446:             instance.options.partialSearch && foundPos != -1) {
0447:             if (instance.options.fullSearch || /\s/.test(elem.substr(foundPos-1,1))) {
0448:               partial.push("<li>" + elem.substr(0, foundPos) + "<strong>" +
0449:                 elem.substr(foundPos, entry.length) + "</strong>" + elem.substr(
0450:                 foundPos + entry.length) + "</li>");
0451:               break;
0452:             }
0453:           }
0454:  
0455:           foundPos = instance.options.ignoreCase ? 
0456:             elem.toLowerCase().indexOf(entry.toLowerCase(), foundPos + 1) : 
0457:             elem.indexOf(entry, foundPos + 1);
0458:  
0459:         }
0460:       }
0461:       if (partial.length)
0462:         ret = ret.concat(partial.slice(0, instance.options.choices - ret.length))
0463:       return "<ul>" + ret.join('') + "</ul>";
0464:     }
0465:   }, options || { });
0466: }
0467:});
0468:

419~468行目は、setOptionsです。このクラスに特有のオプションを追加する関数です。

ここのselectorに、候補を導くロジックが納められていて、Autocompleter.Localのもっとも重要な部分です。String.indexOf()で、語をひとつひとつ調べていきます。indexOfが0なら、前方一致です。indexOfが1以上なら、単語別前方一致の可能性がでてきます。ただし、fullSearchが有効なら、indexOfが1以上なら部分一致がなりたつので、もう単語別前方一致の可能性を考慮する必要はありません。

436行目で、foundPosはindexOfによって、一致がなければ-1に、前方一致であれば0に、どこかに一致があれば1以上になります。

440行目からのループは、単語別前方一致を見つけるためのループです。単なる前方一致や部分一致なら、このループは一度しか回りません。なぜ単語別前方一致の検索にループが必要かというと、例えば'bar afoo foobar'.indexOf('foo')のとき、まずはafooが検出されますが、これはただの部分一致なのでそれをスキップして、次のfoobarを見に行くためです。次のfoobarの1文字前がスペース(正規表現の\s)で単語であるので、単語別前方一致となり、ループを抜けます。

見つかった候補のリストは、HTMLの<ul><li>...</li>...</ul>の表現にして返します。特に、トークンと一致している部分は<strong>で強調表示します。

おすすめ記事

記事・ニュース一覧