script.aculo.usを読み解く

第11回dragdrop.js (前編)

JavaScriptでドラッグ&ドロップを実現するためのライブラリ、dragdrop.jsを、3回に分けて解説していきます。

昨今、メールクライアントなど、これまでデスクトップにあったものがブラウザの上で動くようになりました。それによって、"外出先でも使える""インストール作業がいらない"などの利点はあるものの、GUIとしては退化してしまった感があります。

ドラッグ&ドロップという操作は、物を運んだり、他の場所に置くといった、人間の行動を直感的に表現していて、優れたGUIです。このライブラリを使えば、Webページをよりインタラクティブなものに変えていくことができるはずです。

0001:// script.aculo.us dragdrop.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 Sammi Williams (http://www.oriontransfer.co.nz, sammi@oriontransfer.co.nz)
0005:// 
0006:// script.aculo.us is freely distributable under the terms of an MIT-style license.
0007:// For details, see the script.aculo.us web site: http://script.aculo.us/
0008:

1~8行目は著作権表示です。

0009:if(Object.isUndefined(Effect))
0010:  throw("dragdrop.js requires including script.aculo.us' effects.js library");
0011:

9~11行目で、dragdrop.jsはeffects.jsに依存しているので、effects.jsがロードされているかをチェックしています。

Droppables

ドラッグ&ドロップの、ドロップ先を記述したクラスです。このクラスの機能には、ドラッグが上空に来たときスタイルの変化でドロップを促したり、ドロップした瞬間にフックを呼んだり、ドロップを受け付けるかどうかを制限するといったものがあります。

このクラスにおいてoptionsは、変数名からはただオプション設定をまとめたものに思えますが、実はドロップ先の情報を一手に引き受けまとめている重要なオブジェクトになっています。実際、drops配列にこのoptionsを格納することからも、それがわかります。

ここでまとめて、このクラスに登場するプロパティの意味を解説します。

last_active
現在、ドラッグが上空にあるドロップ先です。マウスイベントで常に更新されます。
options.greedy
デフォルトはtrueです。まだ実装されていませんが、公式wikiの説明では、ドロップ先としてより貪欲に振る舞い、ドラッグが上空にきただけで強制的にドロップさせるオプションのようです。
options.hoverclass
デフォルトはnullです。クラス名が入ります。ドロップを受けつけるドラッグが上空にきたときに、ドロップ先の要素に指定したクラス名を追加するオプションです。例えば、このクラス名に要素を明るくするようなCSSを定義しておくことで、ユーザに、ドロップできることを視覚で伝えることができます。
options.tree
デフォルトはfalseです。後編で解説するSortableでドロップ先がツリーとして振る舞うときに、このオプションが重要になります。
options.containment
DOM idか、DOM idの配列を指定します。ここで指定した要素の子要素のドロップだけを受けつけます。
options._containers
内部的に使います。上述のcontainmentで指定したDOM idに$関数を適用した結果を、ここにキャッシュしておきます。
options.accept
DOM idか、DOM idの配列を指定します。ここで指定した要素のドロップだけを受けつけます。
options.element
内部的に使います。ドロップ先の要素です。
options.onHover
ドロップを受けつけるドラッグが上空にきたときに呼ぶフックです。このフックは引数に、ドロップ先の要素、ドラッグ中の要素、それらの位置の重なり具合、をとります。
options.onDrop
ドロップがあったときに呼ぶフックです。このフックは引数に、ドロップ先の要素、ドロップした要素、マウスイベントのイベントオブジェクト、をとります。

それではコードを見ていきましょう。

0012:var Droppables = {
0013:  drops: [],
0014:

12~14行目のdropsは、ドロップ先の情報をまとめたoptionsを、順番に格納するための配列です。

0015:  remove: function(element) {
0016:    this.drops = this.drops.reject(function(d) { return d.element==$(element) });
0017:  },
0018:

15~18行目のremoveは、引数のドロップ先要素を、drops配列から削除する関数です。

0019:  add: function(element) {
0020:    element = $(element);
0021:    var options = Object.extend({
0022:      greedy:     true,
0023:      hoverclass: null,
0024:      tree:       false
0025:    }, arguments[1] || { });
0026:
0027:    // cache containers
0028:    if(options.containment) {
0029:      options._containers = [];
0030:      var containment = options.containment;
0031:      if(Object.isArray(containment)) {
0032:        containment.each( function(c) { options._containers.push($(c)) });
0033:      } else {
0034:        options._containers.push($(containment));
0035:      }
0036:    }
0037:    
0038:    if(options.accept) options.accept = [options.accept].flatten();
0039:
0040:    Element.makePositioned(element); // fix IE
0041:    options.element = element;
0042:
0043:    this.drops.push(options);
0044:  },
0045:

19~45行目のaddは、引数の要素を、ドロップ先としてdrops配列に追加する関数です。

31行目で、options.containmentがDOM idの配列で与えられた場合は、それぞれに$関数を適用して_containersに追加していきます。これはmap関数を使ってもよいでしょう。

33行目で、同様に、DOM idで与えられた場合も、$関数を適用して追加します。

38行目で、options.acceptには、DOM idの配列か、DOM idを与えることになっていますが、ここでDOM idの配列の形に統一します。そのためにいったん配列にしてflatten関数を呼びます。

40行目で、ドロップ先は移動する必要がないのですが、IEの仕様のため、ここでmakePositionedします。

43行目で、drops配列にoptionsを追加します。

0046:  findDeepestChild: function(drops) {
0047:    deepest = drops[0];
0048:      
0049:    for (i = 1; i < drops.length; ++i)
0050:      if (Element.isParent(drops[i].element, deepest.element))
0051:        deepest = drops[i];
0052:    
0053:    return deepest;
0054:  },
0055:  

46~55行目のfindDeepestChildは、drops配列の要素の中で、もっとも深い子要素になるものを返す関数です。比較にはElement.isParentを使います。

Element.isParentの定義は947~953行目にあります。再帰的に、左辺の要素が右辺の要素に子孫として含まれるかどうかを調べます。

0947:// Returns true if child is contained within element
0948:Element.isParent = function(child, element) {
0949:  if (!child.parentNode || child == element) return false;
0950:  if (child.parentNode == element) return true;
0951:  return Element.isParent(child.parentNode, element);
0952:}
0953:
0056:  isContained: function(element, drop) {
0057:    var containmentNode;
0058:    if(drop.tree) {
0059:      containmentNode = element.treeNode; 
0060:    } else {
0061:      containmentNode = element.parentNode;
0062:    }
0063:    return drop._containers.detect(function(c) { return containmentNode == c });
0064:  },
0065:  

56~65行目のisContainedは、引数に、ドラッグ中の要素と、ドロップ先をとります。ドラッグ中の要素の親要素が、ドロップ先の_containersプロパティに列挙されているかを返す関数です。この判定が、そのドロップを受け付けるかどうかに使われます。

58行目で、Sortableクラスでは、ドロップ先がツリーとして振る舞う場合があります。そこで、drop.treeがtrueのときは、ツリーの根の要素を親要素と考え、element.treeNodeを参照します。

61行目で、通常は、element.parentNodeを参照します。

0066:  isAffected: function(point, element, drop) {
0067:    return (
0068:      (drop.element!=element) &&
0069:      ((!drop._containers) ||
0070:        this.isContained(element, drop)) &&
0071:      ((!drop.accept) ||
0072:        (Element.classNames(element).detect( 
0073:          function(v) { return drop.accept.include(v) } ) )) &&
0074:      Position.within(drop.element, point[0], point[1]) );
0075:  },
0076:

66~76行目のisAffectedは、引数に、ドラッグ中の座標と、ドラッグ中の要素と、ドロップ先をとります。この座標のドラッグをドロップ先が受けつけるかを返す関数です。

70行目で、上述のisContained関数の判定を調べます。

72行目で、ドロップ先のoptions.acceptに含まれるかを調べます。

74行目で、座標がドロップ先の要素の範囲内にあるか調べるために、Position.withinを使います。これらの条件をすべて満たしている必要があります。

0077:  deactivate: function(drop) {
0078:    if(drop.hoverclass)
0079:      Element.removeClassName(drop.element, drop.hoverclass);
0080:    this.last_active = null;
0081:  },
0082:

77~82行目のdeactivateは、引数にドロップ先をとります。ドロップ先からhoverclassのクラス名を削除したり、last_activeプロパティをnullにする関数です。

0083:  activate: function(drop) {
0084:    if(drop.hoverclass)
0085:      Element.addClassName(drop.element, drop.hoverclass);
0086:    this.last_active = drop;
0087:  },
0088:

83~88行目のactivateは、引数にドロップ先をとります。ドラッグが上空にあるドロップ先に、hoverclassのクラス名を追加したり、last_activeプロパティをセットする関数です。last_activeプロパティは、現在ドラッグが上空にあるドロップ先を示す値です。

0089:  show: function(point, element) {
0090:    if(!this.drops.length) return;
0091:    var drop, affected = [];
0092:    
0093:    this.drops.each( function(drop) {
0094:      if(Droppables.isAffected(point, element, drop))
0095:        affected.push(drop);
0096:    });
0097:        
0098:    if(affected.length>0)
0099:      drop = Droppables.findDeepestChild(affected);
0100:
0101:    if(this.last_active && this.last_active != drop) this.deactivate(this.last_active);
0102:    if (drop) {
0103:      Position.within(drop.element, point[0], point[1]);
0104:      if(drop.onHover)
0105:        drop.onHover(element, drop.element, Position.overlap(drop.overlap, drop.element));
0106:      
0107:      if (drop != this.last_active) Droppables.activate(drop);
0108:    }
0109:  },
0110:

89~110行目のshowは、引数にドラッグ中の座標と、ドラッグ中の要素をとります。ドラッグが上空にあるドロップ先を見つけて処理をする関数です。

93行目で、ドロップ先すべてを、isAffected関数でチェックして、この座標、このドラッグ中の要素について、ドロップを受けつけるものをaffected配列に列挙します。

98行目で、affectedの候補がいくつかあった場合は、findDeepestChild関数で、もっともDOMツリーの階層が深い要素を選びます。

101行目で、前回の結果であるlast_activeと変化があったときは、前回の結果にdeactivate関数を呼び、107行目で、今回の結果にactivate関数を呼びます。

104行目で、onHoverフックがあれば呼びます。Position.overlapを呼び出していますが、この関数は直前に、Position.withinを呼ぶ必要があることに注意してください。それが103行目です。

0111:  fire: function(event, element) {
0112:    if(!this.last_active) return;
0113:    Position.prepare();
0114:
0115:    if (this.isAffected([Event.pointerX(event), Event.pointerY(event)], element, this.last_active))
0116:      if (this.last_active.onDrop) {
0117:        this.last_active.onDrop(element, this.last_active.element, event); 
0118:        return true; 
0119:      }
0120:  },
0121:

111~121行目のfireは、引数にイベント情報と、ドラッグした要素をとります。ドラッグ終了直後に呼ばれる関数です。

113行目で、Position.prepareを呼んで、スクロール位置の調整をします。

115行目で、このドロップを受けつけるかをisAffected関数でチェックします。

116行目で、もしonDropフックがあれば呼びます。

0122:  reset: function() {
0123:    if(this.last_active)
0124:      this.deactivate(this.last_active);
0125:  }
0126:}
0127:

122~127行目のresetは、deactivate関数を呼んで、現在ドラッグが上空にあるドロップ先であるlast_activeの状態を、元に戻す関数です。

Draggables

ドラッグ可能要素の全体を管理するためのクラスです。以下で、このクラスに登場するプロパティの意味について解説します。

drags
Draggableクラスのインスタンスをすべて保持する配列です。
observers
SortableObserverクラスのインスタンスが入る配列です。
onStartCount、onEndCount、onDragCount
observers配列にあるフックの数を記憶するためのプロパティです。フックの追加、削除のたびに_cacheObserverCallbacksで更新します。
activeDraggable
現在ドラッグ中の要素を示します。マウスイベントで常に更新します。
_lastPointer
前回のマウスポインタの位置です。前回のマウスイベントと比べてポインタの位置に変化があったときだけ処理を行うために必要です。
_lastScrollPointer
前回のスクロール位置です。
_timeout
activate関数で処理を指定時間遅延するためのタイマです。

それではコードを見ていきましょう。

0128:var Draggables = {
0129:  drags: [],
0130:  observers: [],
0131:  

129、130行目のdrags、observersは上述の通りです。

0132:  register: function(draggable) {
0133:    if(this.drags.length == 0) {
0134:      this.eventMouseUp   = this.endDrag.bindAsEventListener(this);
0135:      this.eventMouseMove = this.updateDrag.bindAsEventListener(this);
0136:      this.eventKeypress  = this.keyPress.bindAsEventListener(this);
0137:      
0138:      Event.observe(document, "mouseup", this.eventMouseUp);
0139:      Event.observe(document, "mousemove", this.eventMouseMove);
0140:      Event.observe(document, "keypress", this.eventKeypress);
0141:    }
0142:    this.drags.push(draggable);
0143:  },
0144:  

132~144行目のregisterは、引数にドラッグ可能要素をとります。drags配列に、ドラッグ可能要素を登録する関数です。

133~141行目で、もしこれが初めての登録ならば、documentオブジェクトに各種のイベントハンドラを設定します。

142行目で、drags配列に要素を追加します。

0145:  unregister: function(draggable) {
0146:    this.drags = this.drags.reject(function(d) { return d==draggable });
0147:    if(this.drags.length == 0) {
0148:      Event.stopObserving(document, "mouseup", this.eventMouseUp);
0149:      Event.stopObserving(document, "mousemove", this.eventMouseMove);
0150:      Event.stopObserving(document, "keypress", this.eventKeypress);
0151:    }
0152:  },
0153:  

145~153行目のunregisterは、引数にドラッグ可能要素をとります。このクラスのdrags配列から、ドラッグ可能要素を削除する関数です。

147~151行目で、もしこれが最終の削除ならば、documentオブジェクトからイベントハンドラを解除します。

0154:  activate: function(draggable) {
0155:    if(draggable.options.delay) { 
0156:      this._timeout = setTimeout(function() { 
0157:        Draggables._timeout = null; 
0158:        window.focus(); 
0159:        Draggables.activeDraggable = draggable; 
0160:      }.bind(this), draggable.options.delay); 
0161:    } else {
0162:      window.focus(); // allows keypress events if window isn't currently focused, fails for Safari
0163:      this.activeDraggable = draggable;
0164:    }
0165:  },
0166:  

154~166行目のactivateは、引数にドラッグ可能要素をとります。現在ドラッグ中の要素を示すactiveDraggableを設定する関数です。指定した時間、処理を遅延する機能があります。

155~160行目で、ドラッグ可能要素のoptions.delayで指定した時間、タイマで処理を遅延します。

158、162行目で、キーボードイベントを受けつけるため、window.focusでフォーカスを持ちます。

159、163行目で、現在ドラッグ中の要素を示すactiveDraggableを更新します。

0167:  deactivate: function() {
0168:    this.activeDraggable = null;
0169:  },
0170:  

167~170行目のdeactivateは、現在ドラッグ中の要素を示すactiveDraggableをnullにする関数です。

0171:  updateDrag: function(event) {
0172:    if(!this.activeDraggable) return;
0173:    var pointer = [Event.pointerX(event), Event.pointerY(event)];
0174:    // Mozilla-based browsers fire successive mousemove events with
0175:    // the same coordinates, prevent needless redrawing (moz bug?)
0176:    if(this._lastPointer && (this._lastPointer.inspect() == pointer.inspect())) return;
0177:    this._lastPointer = pointer;
0178:    
0179:    this.activeDraggable.updateDrag(event, pointer);
0180:  },
0181:  

171~181行目のupdateDragは、documentオブジェクトのmousemoveイベントハンドラです。ドラッグ中の処理をする関数です。

176行目で、前回のマウスポインタの位置と比べて変化があったときに、次の処理に進みます。

179行目で、現在ドラッグ中の要素のupdateDragメソッドを呼びます。

0182:  endDrag: function(event) {
0183:    if(this._timeout) { 
0184:      clearTimeout(this._timeout); 
0185:      this._timeout = null; 
0186:    }
0187:    if(!this.activeDraggable) return;
0188:    this._lastPointer = null;
0189:    this.activeDraggable.endDrag(event);
0190:    this.activeDraggable = null;
0191:  },
0192:  

182~192行目のendDragは、documentオブジェクトのmouseupイベントハンドラです。ドラッグ終了時の処理をする関数です。

189行目で、ドラッグしていた要素のendDragメソッドを呼びます。さらに状態をリセットします。

0193:  keyPress: function(event) {
0194:    if(this.activeDraggable)
0195:      this.activeDraggable.keyPress(event);
0196:  },
0197:  

193~197行目のkeyPressは、documentオブジェクトのkeyPressイベントハンドラです。ドラッグ中の要素のkeyPressメソッドを呼びます。デフォルトでは、これはEscキーのときドラッグをキャンセルする処理につながります。

0198:  addObserver: function(observer) {
0199:    this.observers.push(observer);
0200:    this._cacheObserverCallbacks();
0201:  },
0202:  

198~202行目のaddObserverは、引数にSortableObserverのインスタンスをとります。ドラッグ可能要素全体についてのフックをobservers配列に追加する関数です。

200行目で、_cacheObserverCallbacksを呼んで、フックの数を常に記憶しておきます。

0203:  removeObserver: function(element) {  // element instead of observer fixes mem leaks
0204:    this.observers = this.observers.reject( function(o) { return o.element==element });
0205:    this._cacheObserverCallbacks();
0206:  },
0207:  

203~207行目のremoveObserverは、引数にドラッグ可能要素をとります。observers配列からこの要素にからんだフックをすべて削除する関数です。

203行目のコメントで、フックでなく要素を中心に考えることでメモリリークを防げる旨が書かれています。

205行目で、_cacheObserverCallbacksを呼んで、フックの数を常に記憶しておきます。

0208:  notify: function(eventName, draggable, event) {  // 'onStart', 'onEnd', 'onDrag'
0209:    if(this[eventName+'Count'] > 0)
0210:      this.observers.each( function(o) {
0211:        if(o[eventName]) o[eventName](eventName, draggable, event);
0212:      });
0213:    if(draggable.options[eventName]) draggable.options[eventName](draggable, event);
0214:  },
0215:  

208~215行目のnotifyは、引数にイベント名('onStart'か'onEnd'か'onDrag')と、ドラッグ可能要素と、イベント情報をとります。イベント名に該当するフックを呼ぶ関数です。

209行目で、observers配列の線形探索を何度もしなくてすむように、_cacheObserverCallbacksで、フックの数を常に記憶してあったのが役に立ちます。

210行目で、observers配列からイベント名に該当するフックを呼びます。

213行目で、さらに、ドラッグ可能要素の該当するフックを呼びます。

0216:  _cacheObserverCallbacks: function() {
0217:    ['onStart','onEnd','onDrag'].each( function(eventName) {
0218:      Draggables[eventName+'Count'] = Draggables.observers.select(
0219:        function(o) { return o[eventName]; }
0220:      ).length;
0221:    });
0222:  }
0223:}
0224:
0225:/*--------------------------------------------------------------------------*/
0226:

216~226行目の_cacheObserverCallbacksは、observers配列のなかのSortableObserverのインスタンスのフックの数を常に記憶するための関数です。

217行目で、フックの数を、それぞれonStartCount、onEndCount、onDragCountプロパティに保存します。

おすすめ記事

記事・ニュース一覧