今回のSortableクラスは、ulタグ要素を与えるだけで、中のliタグ要素に全自動でドラッグ&ドロップ機能を適切に割り当てて、並び替えできるリストに変身させてしまう機能を提供します。また、並び替えできるツリーという機能もあり、これはulタグ要素の中のulタグ要素といったツリー構造を扱うために、コードが複雑です。ツリー構造の走査には、再帰呼び出しが活躍します。
SortableObserver
ドラッグの開始、途中、終了という3種のフックをまとめたクラスです。将来的にはObserverクラスを作ろうと考えているのでしょうが、その必要性を計りかねているようです。
576~582行目のinitializeは、インスタンスの初期化をする関数です。引数にドラッグする要素のDOM idと、ドラッグの終了時に呼ぶフックをとります。
580行目で、lastValueプロパティには、serialize関数の結果が入ります。これは並び順を文字列に出力したものです。
583~586行目のonStart関数は、ドラッグの開始時に呼ばれるフックです。このときの並び順を保存しておきます。
587~593行目のonEnd関数は、ドラッグの終了時に呼ばれるフックです。
588行目のunmark関数は、後述するように、マークを非表示にする関数です。
589行目で、ドラッグ開始時と比べて、並び順に変化があれば、observerフック(実体は734行目にあるように、options.onUpdateです)を呼びます。
Sortable
595行目で、SERIALIZE_RULEは、この正規表現に一致するDOM idを持ったアイテム要素だけを処理の対象にします。この正規表現から分かるように、アイテム要素のDOM idは"名称_アイテムID"の形になっている必要があります。
597行目のsortablesは、このクラスのインスタンスを保持するハッシュテーブルです。キーにインスタンスのコンテナ要素のDOM idを、値にそのoptionsをとります。
599~605行目の_findRootElementは、引数の要素のDOM階層を上にたどって、特に、並び替えリストのコンテナになっている親要素を求める関数です。
606~611行目のoptionsは、引数の要素に上述の_findRootElement関数を使って、そのコンテナになっている親要素のoptionsを返す関数です。
612~623行目のdestroyは、引数の要素に上述のoptions関数を使って、そのコンテナになっている親要素から並び替えリストの機能を取り除く関数です。
616行目で、DraggablesクラスのremoveObserver関数でフックを外します。
617行目で、その並び替えリストを構成するDroppablesクラスのインスタンスたちを、それぞれremove関数で削除します。ここで618行目のようにinvoke('remove')を使えないのは、同名の関数であるPrototype.jsのElement.removeが優先して呼ばれてしまうからです。
618行目で、その並び替えリストを構成するDraggablesクラスのインスタンスたちにinvokeメソッドを使って、それぞれdestroy関数で削除します。
620行目で、sortablesハッシュテーブルからこのインスタンスを削除します。
624~740行目のcreate関数は、引数の要素に、全自動でドラッグ&ドロップ機能を適切に割り当てて、並び替えできるリストを作る関数です。
まずは前半部分、624~654行目を扱います。これはオプションを処理します。このクラスに特有なオプションは以下だけで、残りはDraggablesクラスとDroppablesクラスのためのものです。
- tag
- デフォルトは'li'です。並び替えリストのアイテムにする要素のタグ名です。
- dropOnEmpty
- デフォルトはfalseです。trueにすると、並び替えリストのコンテナ自身がドロップ可能要素になるので、アイテムがひとつもないときでもドラッグを受け入れられるようになります。
- tree
- デフォルトはfalseです。trueにすると、並び替えツリーを作ります。並び替えツリーでは、treeTagの要素でできたツリー構造を並び替えることができます。
- treeTag
- デフォルトは'ul'です。並び替えツリーで、ツリー構造を表現する要素のタグ名です。
- only
- デフォルトはfalseです。CSSクラスかその配列を指定します。受けつける要素を、containmentでの制限以上に絞るためのオプションで、それらのクラスのいずれかを持つ要素しか受けつけないようになります(containmentオプションは、前回解説したように、Droppablesクラスのオプションで、要素のDOM idか、DOM idの配列を指定し、その子要素のドロップだけを受けつけるように制限するオプションです)。
- format
- アイテムのDOM id(通常は"名称_アイテムID"の形式)から、名称の部分とアイテムIDの部分の値を取り出すための正規表現です。
- elements
- デフォルトでは、並び替えリストにする要素を全自動で計算するようになっていますが、このオプションに、そのような要素のDOM idの配列を人間の手で記述して渡してやることで処理時間を短縮できます。
- handles
- デフォルトでは、並び替えリストのアイテムのハンドル(把手)にする要素を全自動で計算するようになっていますが、このオプションに、そのような要素のDOM idの配列を人間の手で記述して渡してやることで、処理時間を短縮できます。
- onChange
- ドラッグに伴ってリストの並び順が変わった瞬間に呼ばれるフックです。また、ドラッグ中の要素が別の並び替えリストに出て行ったときも、このフックが呼ばれます。引数にドラッグ中の要素をとります。
- onUpdate
- ドラッグ終了時にリストの並び順が変わっていたときに呼ばれるフックです。引数に並び替えリストのコンテナをとります。このフックの呼び出し元のSortableObserver.onEndにあるように、このフックの動作はSortable.serializeの結果に依存しているので、アイテムのDOM idが適切な形式になっているか、くれぐれも注意してください。
次に、create関数の後半部分、655~740行目を解説します。
656行目で、この要素を含む並び替えリストがあれば、それを破棄します。
658~702行目で、Droppables、Draggablesクラスのためにオプションを割り振ります。これらのオプションの意味は前回を参照してください。
706行目のoptions.draggablesは、この並び替えリストを構成するドラッグ可能要素を保持する配列です。
707行目のoptions.droppablesは、この並び替えリストを構成するドロップ可能要素を保持する配列です。
710行目で、options.dropOnEmptyがtrueか、あるいはoptions.treeがtrueならば、このコンテナ自身をドロップ可能要素にします。
715~732行目が、コンテナ内部の要素に適切にドラッグ&ドロップ機能を加えて、並び替えリストを構築する処理です。
715行目で、後述のfindElements関数で、コンテナ内部で並び替えリストのアイテムになるべき要素たちを求めます。ただし前述のとおり、options.elementsにそれを記述してやることで、この関数の呼び出しを省略できるようになっています。
716行目で、アイテムのなかに、ハンドル(把手)になるべき要素があれば取り出します。
718行目で、アイテムをドラッグ可能要素にします。
720行目で、アイテムをドロップ可能要素にします。並び替えリストというと、アイテムをドラッグだけできれば良さそうな気もしますが、ドラッグ中のアイテムが"挿入位置のアイテムを押しのける"ように見える動作は、実際は、ドロップ可能要素が"上空のドラッグ中のアイテムを避ける"ことで実現されているので、ドロップ可能にしておく必要があります。
721行目で、options.treeがtrueならば、アイテムのツリー構造上の親ノードをこのコンテナにします。
725行目で、options.treeがtrueならば、後述のfindTreeElements関数で、コンテナ内部で並び替えツリーのツリー構造になる要素たちを求め、eachメソッドで727~729行目の処理を行います。
727行目で、これらの要素をドロップ可能にします。これでツリーのドラッグを受け入れられるようになります。
729行目で、これらの要素のツリー構造上の親ノードをこのコンテナにします。
734行目で、sortablesハッシュテーブルに、このコンテナを追加します。
737行目で、このコンテナ全体のフックを追加します。
741~746行目のfindElementsは、並び替えリストが含むアイテム要素を列挙する関数です。内部的には、後述のElement.findChildren関数を使っています。有り体にいえば、引数の要素以下のDOMツリーをたどって、'li'タグ要素を列挙する関数です。
747~751行目のfindTreeElementsは、並び替えツリーに含まれるツリー要素を列挙する関数です。内部的には、後述のElement.findChildren関数を使っています。有り体にいえば、引数の要素以下のDOMツリーをたどって、'ul'タグ要素を列挙する関数です。
752~780行目のonHoverは、引数に、ドラッグ中の要素、そのドラッグが上空にきているドロップ可能要素、それらの要素の重なり具合をとります。これは、ドラッグ中のアイテムが"挿入位置のアイテムを押しのける"ように見える動作を行う関数です。この動作は実際は、ドロップ可能要素が"上空のドラッグ中のアイテムを避ける"ことで実現されています。ここで問題になるのは、前に避けるか、後に避けるかということです。要素の重なり具合からそれを判断します。
753行目で、Element.isParentは後述しますが、つまり、もしドロップ先の要素が、ドラッグ中の要素の子孫に含まれていたら、何も処理しないということです。
755行目で、overlapの値が(0.33~0.66)の範囲にあり、ドロップ先のoptions.treeがtrueならば、何も処理しません。つまり、だいたい中央に重なっていて、かつドロップ先が並び替えツリーならば、避ける動作をしないということです。
757~766行目が、重なりのズレが"前側"のときの処理です。このときは、挿入位置はドロップ先要素の直前になります。
758行目で、後述のmark関数で、マーカーの位置を、ドロップ先要素の"前側"にします。
以下で、ドロップ先要素のDOMに挿入して、"挿入位置のアイテムを押しのける"動作をします。
759行目で、既にDOMに挿入してあるなら、何もしません。
760行目で、並び替えリスト間のアイテムの移動を検知するため、親ノードを記憶しておきます。
761行目で、"押しのける"ことでスペースが空いたように見えるのは、見えない要素がそこに挿入されるからです。
762行目で、ドロップ先要素のDOM位置の直前に挿入します。
763行目で、並び替えリスト間のアイテムの移動があったら、出どころの並び替えリストのonChangeフックを呼びます。
765行目で、ドロップ先の並び替えリストのonChangeフックを呼びます。
以降は、重なりのズレが"後側"のときの処理です。ほぼ同様なので解説は省略します。
781~813行目のonEmptyHoverは、ドラッグ中のアイテムをドロップ先の並び替えツリーのDOMに追加する関数です。それらの重なり具合と、要素ごとの位置関係を加味して、より自然な操作感を実現しています。引数に、ドラッグ中のアイテム、ドロップ先の並び替えツリー、それらの重なり具合、を取ります。
785行目で、Element.isParentは後述しますが、つまり、もしドロップ先の要素が、ドラッグ中の要素の子孫に含まれていたら、何も処理しないということです。
788行目で、findElements関数で、ドロップ先の並び替えツリーのアイテムを全て列挙します。
791~806行目で、それらのアイテムの位置関係から、ドラッグ中の要素を挿入するのにふさわしい位置にあるアイテムを求めます。
807行目で、ふさわしい位置にあるアイテムの直前にドラッグ中の要素を挿入します。
809行目で、ドラッグ中のアイテムが属していた並び替えツリーのonChangeフックを呼びます。
810行目で、ドロップ先の並び替えツリーのonChangeフックを呼びます。
814~817行目のunmarkは、マーカーを非表示にする関数です。
818~840行目のmarkは、マーカーを適切な位置に表示する関数です。引数にドロップ先の要素と、表示位置('before'か'after')をとります。
821行目で、コメントにもあるように、マーカーを表示するのは、ドロップ先の並び替えリストのoptions.ghostingが有効なときだけです。
823行目で、マーカーとなるべき要素が用意されていなかった場合は、即席にdiv要素を作り、表示を隠し、クラス名に'dropmarker'を加え、positionプロパティを'absolute'にします。
827行目で、そのdiv要素をbodyのすぐ下に追加します。
829行目で、Position.cumulativeOffsetで、ドロップ先の要素の位置を取得します。
830行目で、マーカーの位置は、表示位置が'before'ならば、ドロップ先の要素の直前の位置です。
832行目で、表示位置が'after'ならば、ドロップ先の要素の直後の位置です。ドロップ先の要素の幅、あるいは高さ分を補正します。
838行目で、マーカーを表示します。
841~867行目の_treeは、並び替えツリーのツリー構造を再帰的にたどり、すべてのアイテムを列挙した配列を返す関数です。引数に並び替えツリーのコンテナと、そのoptionsと、ツリー構造の親になっているアイテムをとります。この関数はserialize関数によって、すべてのアイテムの並び順を文字列に直すために使われます。
842行目で、findElements関数で、このコンテナが含むアイテムを取りだします。コンテナが含むコンテナのアイテムなどは取り出されないことに注意してください。
845行目で、取りだしたアイテムのDOM idがoptions.formatの形式(デフォルトでは"名称_アイテムID"の形式)に一致するか確かめます。
849~856行目で、アイテムの情報を作ります。
850行目のidは、アイテムのDOM idのアイテムIDの部分です。
851行目のelementは、このコンテナ自身です。
852行目のparentは、入れ子構造の親になっているアイテムです。
853行目のchildrenは、このコンテナに含まれる子アイテムのリストです。
854行目のpositionは、このコンテナが親にとって何番めの子かを表します。
855行目のcontainerは、このコンテナが含む、子のコンテナです。
859行目で、このコンテナに子のコンテナがあれば、それを再帰的にたどります。
862行目で、ツリー構造の親のアイテムのchildrenにこのアイテムを追加します。
865行目で、ツリー構造の親を返します。
868~889行目のtreeは、上述の_tree関数を再帰的に呼び出す起点となる関数です。引数に起点となる並び替えツリーのコンテナをとります。2番めの引数にそのオプションをとることができます。
879行目で、ツリー構造の根として、rootアイテムを作ります。
887行目で、上述の_tree関数を呼び出します。
890~898行目の_constructIndexは、引数にtree関数がつくるアイテム情報をとり、そのツリー構造をrootアイテムに向かってたどりながら、それらのpositionの情報からアイテムのツリー構造上のインデックスを表現した"[i][j]..."という文字列を作って返す関数です。
899~907行目のsequenceは、並び替えリストのコンテナを引数にとり、その並び順をアイテムIDの配列の形で返す関数です。2番めの引数に並び替えリストのオプションを取ることができます。
903行目で、findElementsでこのコンテナが含むアイテムを求めます。
904行目で、各アイテムのDOM id(デフォルトでは"名称_アイテムID"の形式)からアイテムIDを取り出します。
908~927行目のsetSequenceは、並び替えリストのアイテムの順番を、並び順を記述したアイテムIDの配列のとおりにする関数です。引数に並び替えリストのコンテナと、並び順を記述したアイテムIDの配列をとります。3番目の引数に並び替えリストのオプションを取ることができます。
913行目で、findElementsでこのコンテナが含むアイテムを求めます。
914行目で、各アイテムのDOM id(デフォルトでは"名称_アイテムID"の形式)からアイテムIDを取り出します。
915行目で、nodeMapのハッシュテーブルに、キーをそのアイテムID、値を[アイテムの要素、アイテムの親要素]として追加します。
916行目で、アイテムのDOMの親子関係を切り離します。次でもう一度繋ぐことで、並び替えができます。
919行目で、並び順を記述したアイテムIDの配列にeachメソッドを使い、以下の処理で、アイテムのDOMの親子関係を再び繋ぎます。
920行目で、nodeMapハッシュテーブルから値を取り出すと、それが順序の互換関係になります。
922行目で、アイテムのDOMの親子関係を再び繋ぎます。
923行目で、一応、nodeMapから使用済みのアイテムを削除しておきます。
928~946行目のserializeは、現在の並び順を、Ajaxで送信するのに便利なクエリパラメータの形で出力する関数です。引数に並び替えリストのコンテナをとります。2番めの引数にそのオプションを取ることができます。
931行目で、変数nameにコンテナ名を設定します。デフォルトではコンテナのDOM idですが、オプションで指定することもできます。
934行目で、並び替えツリーであれば、上述のtree関数でアイテムの入れ子構造を計算します。rootアイテムのchildrenにmapメソッドをかけて、まずは入れ子の最初の段を処理します。
936行目で、コンテナ名やインデックスやアイテムIDを文字列結合します。さらにアイテムのchildren、つまりツリー構造の次の段に向かって、arguments.callee、すなわちこのserialize関数を、再帰的に適用します。
938行目で、配列の入れ子構造をflattenメソッドでつぶして、クエリパラメータとして&を区切り文字にして結合します。
939行目で、ツリーでなく、並び替えリストであれば、sequence関数の結果を整形するだけなので難しくありません。
947~953行目のElement.isParentは、引数に要素child、要素elementをとり、childがelementに含まれるかどうかを返します。
951行目で、再帰的にchild.parentNodeをたどって調べます。
954~971行目のElement.findChildrenは、要素のDOMを下にたどって、指定のタグ名をもつ要素をすべて列挙する関数です。引数に、起点になる要素、CSSクラスの文字列(あるいはその配列)、再帰的にたどるかどうかのフラグ、探すタグ名をとります。
955行目で、起点となる要素に子ノードがなければnullを返します。
957行目で、onlyの値をCSSクラスの文字列の配列に統一するため、一度配列にしてからflattenメソッドを使います。
959行目で、子ノードをたどります。
960行目で、指定のタグ名に一致し、かつ、onlyのCSSクラスを持つノードを、elements配列に追加します。
963行目で、再帰的にたどるならば、子ノードの子ノードを同様に調べます。
965行目で、孫の結果をelements配列に追加します。
969行目で、elements配列の入れ子構造をflattenメソッドでつぶして返します。
972~974行目のElement.offsetSizeは、要素のoffsetHeightかoffsetWidthを返す関数です。引数に要素と、どちらを返すかの指定をとります。
973行目で、指定が'vertical'か'height'なら要素のoffsetHeightを返し、それ以外ならoffsetWidthを返します。
dragdrop.jsの解説は以上です。
おわりに
これで、script.aculo.usのコードを全て解説しました。このライブラリの4000行を越えるコードを読み解くにあたって、Javascriptの動的性が非常に役立ったことが印象的でした。Firebugでたくさんの情報を実行中に取り出せること、トップレベルから関数単位に呼び出してテストできることで、多くの疑問を解決できました。
また、このscript.aculo.usのコード自体も、一貫性のあるクラス設計と、関数単位のボトムアップな実装のおかげで、大変読みやすいものでした。さらに、プロジェクト全体としても、アジャイル開発プロセスに基づいて徹底的なテストが記述されていることや、SubversionやGitによるソースコード管理、Tracによるバグ管理などが整備されていることで、大いに助かりました。
最後に、本連載の草稿を一緒に読み、種々の誤り等を指摘してくれた名古屋大学大学院情報科学研究科修士1年結縁研究室の水野洋樹氏、連載執筆のあいだお世話になった技術評論社株式会社の高橋和道氏に深謝いたします。