Thomas Bruederli
2014-05-12 a2f8fa236143b44f90e53c19806cfd0efa014857
commit | author | age
b34d67 1 /**
TB 2  * Roundcube Treelist Widget
3  *
4  * This file is part of the Roundcube Webmail client
5  *
6  * @licstart  The following is the entire license notice for the
7  * JavaScript code in this file.
8  *
9  * Copyright (c) 2013-2014, The Roundcube Dev Team
10  *
11  * The JavaScript code in this page is free software: you can
12  * redistribute it and/or modify it under the terms of the GNU
13  * General Public License (GNU GPL) as published by the Free Software
14  * Foundation, either version 3 of the License, or (at your option)
15  * any later version.  The code is distributed WITHOUT ANY WARRANTY;
16  * without even the implied warranty of MERCHANTABILITY or FITNESS
17  * FOR A PARTICULAR PURPOSE.  See the GNU GPL for more details.
18  *
19  * As additional permission under GNU GPL version 3 section 7, you
20  * may distribute non-source (e.g., minimized or compacted) forms of
21  * that code without the copy of the GNU GPL normally required by
22  * section 4, provided you include this license notice and a URL
23  * through which recipients can access the Corresponding Source.
24  *
25  * @licend  The above is the entire license notice
26  * for the JavaScript code in this file.
27  *
28  * @author Thomas Bruederli <roundcube@gmail.com>
29  * @requires jquery.js, common.js
30  */
3c309a 31
TB 32
33 /**
34  * Roundcube Treelist widget class
35  * @contructor
36  */
37 function rcube_treelist_widget(node, p)
38 {
ec0f74 39   // apply some defaults to p
AM 40   p = $.extend({
41     id_prefix: '',
42     autoexpand: 1000,
43     selectable: false,
72975e 44     scroll_delay: 500,
TB 45     scroll_step: 5,
46     scroll_speed: 20,
ec0f74 47     check_droptarget: function(node){ return !node.virtual }
AM 48   }, p || {});
3c309a 49
ec0f74 50   var container = $(node),
AM 51     data = p.data || [],
52     indexbyid = {},
53     selection = null,
54     drag_active = false,
eb9551 55     has_focus = false,
ec0f74 56     box_coords = {},
AM 57     item_coords = [],
58     autoexpand_timer,
59     autoexpand_item,
60     body_scroll_top = 0,
61     list_scroll_top = 0,
72975e 62     scroll_timer,
ec0f74 63     me = this;
3c309a 64
TB 65
ec0f74 66   /////// export public members and methods
3c309a 67
ec0f74 68   this.container = container;
AM 69   this.expand = expand;
70   this.collapse = collapse;
71   this.select = select;
72   this.render = render;
817c98 73   this.reset = reset;
ec0f74 74   this.drag_start = drag_start;
AM 75   this.drag_end = drag_end;
76   this.intersects = intersects;
77   this.update = update_node;
78   this.insert = insert;
79   this.remove = remove;
80   this.get_item = get_item;
81   this.get_selection = get_selection;
3c309a 82
ec0f74 83   /////// startup code (constructor)
3c309a 84
ec0f74 85   // abort if node not found
AM 86   if (!container.length)
87     return;
3c309a 88
ec0f74 89   if (p.data)
AM 90     index_data({ children:data });
91   // load data from DOM
92   else
93     update_data();
3c309a 94
ec0f74 95   // register click handlers on list
AM 96   container.on('click', 'div.treetoggle', function(e){
97     toggle(dom2id($(this).parent()));
519ce2 98     e.stopPropagation();
ec0f74 99   });
3c309a 100
ec0f74 101   container.on('click', 'li', function(e){
AM 102     var node = p.selectable ? indexbyid[dom2id($(this))] : null;
103     if (node && !node.virtual) {
104       select(node.id);
105       e.stopPropagation();
106     }
107   });
3c309a 108
eb9551 109   container.on('focusin', function(e){
TB 110       // TODO: only accept focus on virtual nodes from keyboard events
111       has_focus = true;
112     })
113     .on('focusout', function(e){
114       has_focus = false;
115     });
116
e8bcf0 117   container.attr('role', 'tree');
eb9551 118
TB 119   $(document.body)
120     .bind('keydown', keypress);
e8bcf0 121
3c309a 122
ec0f74 123   /////// private methods
3c309a 124
ec0f74 125   /**
AM 126    * Collaps a the node with the given ID
127    */
128   function collapse(id, recursive, set)
129   {
130     var node;
3c309a 131
ec0f74 132     if (node = indexbyid[id]) {
AM 133       node.collapsed = typeof set == 'undefined' || set;
134       update_dom(node);
3c309a 135
ec0f74 136       if (recursive && node.children) {
AM 137         for (var i=0; i < node.children.length; i++) {
138           collapse(node.children[i].id, recursive, set);
139         }
140       }
3c309a 141
ec0f74 142       me.triggerEvent(node.collapsed ? 'collapse' : 'expand', node);
AM 143     }
144   }
3c309a 145
ec0f74 146   /**
AM 147    * Expand a the node with the given ID
148    */
149   function expand(id, recursive)
150   {
151     collapse(id, recursive, false);
152   }
3c309a 153
ec0f74 154   /**
AM 155    * Toggle collapsed state of a list node
156    */
157   function toggle(id, recursive)
158   {
159     var node;
160     if (node = indexbyid[id]) {
161       collapse(id, recursive, !node.collapsed);
162     }
163   }
3c309a 164
ec0f74 165   /**
AM 166    * Select a tree node by it's ID
167    */
168   function select(id)
169   {
170     if (selection) {
eb9551 171       id2dom(selection).removeClass('selected').removeAttr('aria-selected');
ec0f74 172       selection = null;
AM 173     }
3c309a 174
ec0f74 175     var li = id2dom(id);
AM 176     if (li.length) {
eb9551 177       li.addClass('selected').attr('aria-selected', 'true');
ec0f74 178       selection = id;
AM 179       // TODO: expand all parent nodes if collapsed
180       scroll_to_node(li);
181     }
3c309a 182
ec0f74 183     me.triggerEvent('select', indexbyid[id]);
AM 184   }
3c309a 185
ec0f74 186   /**
AM 187    * Getter for the currently selected node ID
188    */
189   function get_selection()
190   {
191     return selection;
192   }
3c309a 193
ec0f74 194   /**
AM 195    * Return the DOM element of the list item with the given ID
196    */
197   function get_item(id)
198   {
199     return id2dom(id).get(0);
200   }
3c309a 201
ec0f74 202   /**
AM 203    * Insert the given node
204    */
205   function insert(node, parent_id, sort)
206   {
207     var li, parent_li,
208       parent_node = parent_id ? indexbyid[parent_id] : null;
344943 209
ec0f74 210     // insert as child of an existing node
AM 211     if (parent_node) {
212       if (!parent_node.children)
213         parent_node.children = [];
344943 214
ec0f74 215       parent_node.children.push(node);
AM 216       parent_li = id2dom(parent_id);
344943 217
ec0f74 218       // re-render the entire subtree
AM 219       if (parent_node.children.length == 1) {
220         render_node(parent_node, parent_li.parent(), parent_li);
221         li = id2dom(node.id);
222       }
223       else {
224         // append new node to parent's child list
225         li = render_node(node, parent_li.children('ul').first());
226       }
227     }
228     // insert at top level
229     else {
230       data.push(node);
231       li = render_node(node, container);
232     }
344943 233
ec0f74 234     indexbyid[node.id] = node;
344943 235
ec0f74 236     if (sort) {
AM 237       resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
238     }
239   }
344943 240
ec0f74 241   /**
AM 242    * Update properties of an existing node
243    */
244   function update_node(id, updates, sort)
245   {
246     var li, node = indexbyid[id];
344943 247
ec0f74 248     if (node) {
AM 249       li = id2dom(id);
344943 250
ec0f74 251       if (updates.id || updates.html || updates.children || updates.classes) {
AM 252         $.extend(node, updates);
253         render_node(node, li.parent(), li);
254       }
344943 255
ec0f74 256       if (node.id != id) {
AM 257         delete indexbyid[id];
258         indexbyid[node.id] = node;
259       }
344943 260
ec0f74 261       if (sort) {
AM 262         resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
263       }
264     }
265   }
344943 266
ec0f74 267   /**
AM 268    * Helper method to sort the list of the given item
269    */
270   function resort_node(li, filter)
271   {
272     var first, sibling,
273       myid = li.get(0).id,
274       sortname = li.children().first().text().toUpperCase();
344943 275
ec0f74 276     li.parent().children('li' + filter).each(function(i, elem) {
AM 277       if (i == 0)
278         first = elem;
279       if (elem.id == myid) {
280         // skip
281       }
282       else if (elem.id != myid && sortname >= $(elem).children().first().text().toUpperCase()) {
283         sibling = elem;
284       }
285       else {
286         return false;
287       }
288     });
344943 289
ec0f74 290     if (sibling) {
AM 291       li.insertAfter(sibling);
292     }
293     else if (first.id != myid) {
294       li.insertBefore(first);
295     }
344943 296
ec0f74 297     // reload data from dom
AM 298     update_data();
299   }
344943 300
ec0f74 301   /**
AM 302    * Remove the item with the given ID
303    */
304   function remove(id)
305   {
306     var node, li;
344943 307
ec0f74 308     if (node = indexbyid[id]) {
AM 309       li = id2dom(id);
310       li.remove();
344943 311
ec0f74 312       node.deleted = true;
AM 313       delete indexbyid[id];
344943 314
ec0f74 315       return true;
AM 316     }
344943 317
ec0f74 318     return false;
AM 319   }
320
321   /**
322    * (Re-)read tree data from DOM
323    */
324   function update_data()
325   {
a2f8fa 326     data = walk_list(container, 0);
ec0f74 327   }
AM 328
329   /**
330    * Apply the 'collapsed' status of the data node to the corresponding DOM element(s)
331    */
332   function update_dom(node)
333   {
334     var li = id2dom(node.id);
a2f8fa 335     li.attr('aria-expanded', node.collapsed ? 'false' : 'true');
ec0f74 336     li.children('ul').first()[(node.collapsed ? 'hide' : 'show')]();
AM 337     li.children('div.treetoggle').removeClass('collapsed expanded').addClass(node.collapsed ? 'collapsed' : 'expanded');
338     me.triggerEvent('toggle', node);
339   }
3c309a 340
ec0f74 341   /**
817c98 342    *
TB 343    */
344   function reset()
345   {
346     select('');
347
348     data = [];
349     indexbyid = {};
350     drag_active = false;
351
352     container.html('');
353   }
354
355   /**
ec0f74 356    * Render the tree list from the internal data structure
AM 357    */
358   function render()
359   {
360     if (me.triggerEvent('renderBefore', data) === false)
361       return;
3c309a 362
ec0f74 363     // remove all child nodes
AM 364     container.html('');
3c309a 365
ec0f74 366     // render child nodes
AM 367     for (var i=0; i < data.length; i++) {
368       render_node(data[i], container);
369     }
3c309a 370
ec0f74 371     me.triggerEvent('renderAfter', container);
AM 372   }
3c309a 373
ec0f74 374   /**
AM 375    * Render a specific node into the DOM list
376    */
377   function render_node(node, parent, replace)
378   {
379     if (node.deleted)
380       return;
344943 381
ec0f74 382     var li = $('<li>')
AM 383       .attr('id', p.id_prefix + (p.id_encode ? p.id_encode(node.id) : node.id))
eb9551 384       .attr('role', 'treeitem')
ec0f74 385       .addClass((node.classes || []).join(' '));
344943 386
ec0f74 387     if (replace)
AM 388       replace.replaceWith(li);
389     else
390       li.appendTo(parent);
344943 391
ec0f74 392     if (typeof node.html == 'string')
AM 393       li.html(node.html);
394     else if (typeof node.html == 'object')
395       li.append(node.html);
3c309a 396
ec0f74 397     if (node.virtual)
AM 398       li.addClass('virtual');
399     if (node.id == selection)
400       li.addClass('selected');
3c309a 401
ec0f74 402     // add child list and toggle icon
AM 403     if (node.children && node.children.length) {
a2f8fa 404       li.attr('aria-expanded', node.collapsed ? 'false' : 'true');
ec0f74 405       $('<div class="treetoggle '+(node.collapsed ? 'collapsed' : 'expanded') + '">&nbsp;</div>').appendTo(li);
a2f8fa 406       var ul = $('<ul>').appendTo(li).attr('class', node.childlistclass).attr('role', 'group');
ec0f74 407       if (node.collapsed)
AM 408         ul.hide();
3c309a 409
ec0f74 410       for (var i=0; i < node.children.length; i++) {
AM 411         render_node(node.children[i], ul);
412       }
413     }
344943 414
ec0f74 415     return li;
AM 416   }
3c309a 417
ec0f74 418   /**
AM 419    * Recursively walk the DOM tree and build an internal data structure
420    * representing the skeleton of this tree list.
421    */
a2f8fa 422   function walk_list(ul, level)
ec0f74 423   {
AM 424     var result = [];
425     ul.children('li').each(function(i,e){
426       var li = $(e), sublist = li.children('ul');
427       var node = {
428         id: dom2id(li),
429         classes: li.attr('class').split(' '),
430         virtual: li.hasClass('virtual'),
431         html: li.children().first().get(0).outerHTML,
a2f8fa 432         children: walk_list(sublist, level+1)
ec0f74 433       }
3c309a 434
ec0f74 435       if (sublist.length) {
AM 436         node.childlistclass = sublist.attr('class');
437       }
438       if (node.children.length) {
439         node.collapsed = sublist.css('display') == 'none';
a2f8fa 440         li.attr('aria-expanded', node.collapsed ? 'false' : 'true');
ec0f74 441       }
AM 442       if (li.hasClass('selected')) {
eb9551 443         li.attr('aria-selected', 'true');
ec0f74 444         selection = node.id;
AM 445       }
3c309a 446
e8bcf0 447       // declare list item as treeitem
TB 448       li.attr('role', 'treeitem');
449
eb9551 450       // allow virtual nodes to receive focus
TB 451       if (node.virtual) {
452         li.children('a:first').attr('tabindex', '0');
453       }
454
ec0f74 455       result.push(node);
AM 456       indexbyid[node.id] = node;
eb9551 457     });
TB 458
a2f8fa 459     ul.attr('role', level == 0 ? 'tree' : 'group');
3c309a 460
ec0f74 461     return result;
AM 462   }
3c309a 463
ec0f74 464   /**
AM 465    * Recursively walk the data tree and index nodes by their ID
466    */
467   function index_data(node)
468   {
469     if (node.id) {
470       indexbyid[node.id] = node;
471     }
472     for (var c=0; node.children && c < node.children.length; c++) {
473       index_data(node.children[c]);
474     }
475   }
3c309a 476
ec0f74 477   /**
AM 478    * Get the (stripped) node ID from the given DOM element
479    */
480   function dom2id(li)
481   {
482     var domid = li.attr('id').replace(new RegExp('^' + (p.id_prefix) || '%'), '');
483     return p.id_decode ? p.id_decode(domid) : domid;
484   }
3c309a 485
ec0f74 486   /**
AM 487    * Get the <li> element for the given node ID
488    */
489   function id2dom(id)
490   {
491     var domid = p.id_encode ? p.id_encode(id) : id;
492     return $('#' + p.id_prefix + domid);
493   }
3c309a 494
ec0f74 495   /**
AM 496    * Scroll the parent container to make the given list item visible
497    */
498   function scroll_to_node(li)
499   {
500     var scroller = container.parent(),
501       current_offset = scroller.scrollTop(),
502       rel_offset = li.offset().top - scroller.offset().top;
d6185f 503
ec0f74 504     if (rel_offset < 0 || rel_offset + li.height() > scroller.height())
AM 505       scroller.scrollTop(rel_offset + current_offset);
506   }
3c309a 507
eb9551 508   /**
TB 509    * Handler for keyboard events on treelist
510    */
511   function keypress(e)
512   {
513     var target = e.target || {},
514       keyCode = rcube_event.get_keycode(e);
515
516     if (!has_focus || target.nodeName == 'INPUT' || target.nodeName == 'TEXTAREA' || target.nodeName == 'SELECT')
517       return true;
518
519     switch (keyCode) {
520       case 38:
521       case 40:
522       case 63232: // 'up', in safari keypress
523       case 63233: // 'down', in safari keypress
524         var li = container.find(':focus').closest('li');
525         if (li.length) {
526           focus_next(li, (mod = keyCode == 38 || keyCode == 63232 ? -1 : 1));
527         }
528         break;
529
530       case 37: // Left arrow key
531       case 39: // Right arrow key
532         var id, node, li = container.find(':focus').closest('li');
533         if (li.length) {
534           id = dom2id(li);
535           node = indexbyid[id];
536           if (node && node.children.length)
537             toggle(id, rcube_event.get_modifier(e) == SHIFT_KEY);  // toggle subtree
538         }
539         return false;
540     }
541
542     return true;
543   }
544
545   function focus_next(li, dir, from_child)
546   {
547     var mod = dir < 0 ? 'prev' : 'next',
548       next = li[mod](), limit, parent;
549
550     if (dir > 0 && !from_child && li.children('ul[role=tree]:visible').length) {
551       li.children('ul').children('li:first').children('a:first').focus();
552     }
553     else if (dir < 0 && !from_child && next.children('ul[role=tree]:visible').length) {
554       next.children('ul').children('li:last').children('a:last').focus();
555     }
556     else if (next.length && next.children('a:first')) {
557       next.children('a:first').focus();
558     }
559     else {
560       parent = li.parent().closest('li[role=treeitem]');
561       if (parent.length)
562         if (dir < 0) {
563           parent.children('a:first').focus();
564         }
565         else {
566           focus_next(parent, dir, true);
567         }
568     }
569   }
570
571
ec0f74 572   ///// drag & drop support
3c309a 573
ec0f74 574   /**
AM 575    * When dragging starts, compute absolute bounding boxes of the list and it's items
576    * for faster comparisons while mouse is moving
577    */
578   function drag_start()
579   {
580     var li, item, height,
581       pos = container.offset();
3c309a 582
ec0f74 583     body_scroll_top = bw.ie ? 0 : window.pageYOffset;
AM 584     list_scroll_top = container.parent().scrollTop();
72975e 585     pos.top += list_scroll_top;
3c309a 586
ec0f74 587     drag_active = true;
AM 588     box_coords = {
589       x1: pos.left,
590       y1: pos.top,
591       x2: pos.left + container.width(),
592       y2: pos.top + container.height()
593     };
3c309a 594
ec0f74 595     item_coords = [];
AM 596     for (var id in indexbyid) {
597       li = id2dom(id);
598       item = li.children().first().get(0);
599       if (height = item.offsetHeight) {
600         pos = $(item).offset();
72975e 601         pos.top += list_scroll_top;
ec0f74 602         item_coords[id] = {
AM 603           x1: pos.left,
604           y1: pos.top,
605           x2: pos.left + item.offsetWidth,
606           y2: pos.top + height,
607           on: id == autoexpand_item
608         };
609       }
610     }
72975e 611
TB 612     // enable auto-scrolling of list container
613     if (container.height() > container.parent().height()) {
614       container.parent()
615         .mousemove(function(e) {
616           var scroll = 0,
617             mouse = rcube_event.get_mouse_pos(e);
618           mouse.y -= container.parent().offset().top;
619
620           if (mouse.y < 25 && list_scroll_top > 0) {
621             scroll = -1; // up
622           }
623           else if (mouse.y > container.parent().height() - 25) {
624             scroll = 1; // down
625           }
626
627           if (drag_active && scroll != 0) {
628             if (!scroll_timer)
629               scroll_timer = window.setTimeout(function(){ drag_scroll(scroll); }, p.scroll_delay);
630           }
631           else if (scroll_timer) {
632             window.clearTimeout(scroll_timer);
633             scroll_timer = null;
634           }
635         })
636         .mouseleave(function() {
637           if (scroll_timer) {
638             window.clearTimeout(scroll_timer);
639             scroll_timer = null;
640           }
641         });
642     }
ec0f74 643   }
3c309a 644
ec0f74 645   /**
AM 646    * Signal that dragging has stopped
647    */
648   function drag_end()
649   {
650     drag_active = false;
72975e 651     scroll_timer = null;
3c309a 652
ec0f74 653     if (autoexpand_timer) {
AM 654       clearTimeout(autoexpand_timer);
655       autoexpand_timer = null;
656       autoexpand_item = null;
657     }
3c309a 658
ec0f74 659     $('li.droptarget', container).removeClass('droptarget');
AM 660   }
3c309a 661
ec0f74 662   /**
72975e 663    * Scroll list container in the given direction
TB 664    */
665   function drag_scroll(dir)
666   {
667     if (!drag_active)
668       return;
669
670     var old_top = list_scroll_top;
671     container.parent().get(0).scrollTop += p.scroll_step * dir;
672     list_scroll_top = container.parent().scrollTop();
673     scroll_timer = null;
674
675     if (list_scroll_top != old_top)
676       scroll_timer = window.setTimeout(function(){ drag_scroll(dir); }, p.scroll_speed);
677   }
678
679   /**
ec0f74 680    * Determine if the given mouse coords intersect the list and one if its items
AM 681    */
682   function intersects(mouse, highlight)
683   {
684     // offsets to compensate for scrolling while dragging a message
685     var boffset = bw.ie ? -document.documentElement.scrollTop : body_scroll_top,
72975e 686       moffset = container.parent().scrollTop(),
ec0f74 687       result = null;
3c309a 688
72975e 689     mouse.top = mouse.y + moffset - boffset;
3c309a 690
ec0f74 691     // no intersection with list bounding box
AM 692     if (mouse.x < box_coords.x1 || mouse.x >= box_coords.x2 || mouse.top < box_coords.y1 || mouse.top >= box_coords.y2) {
693       // TODO: optimize performance for this operation
694       $('li.droptarget', container).removeClass('droptarget');
695       return result;
696     }
3c309a 697
ec0f74 698     // check intersection with visible list items
AM 699     var pos, node;
700     for (var id in item_coords) {
701       pos = item_coords[id];
702       if (mouse.x >= pos.x1 && mouse.x < pos.x2 && mouse.top >= pos.y1 && mouse.top < pos.y2) {
703         node = indexbyid[id];
3c309a 704
ec0f74 705         // if the folder is collapsed, expand it after the configured time
AM 706         if (node.children && node.children.length && node.collapsed && p.autoexpand && autoexpand_item != id) {
707           if (autoexpand_timer)
708             clearTimeout(autoexpand_timer);
3c309a 709
ec0f74 710           autoexpand_item = id;
AM 711           autoexpand_timer = setTimeout(function() {
712             expand(autoexpand_item);
713             drag_start();  // re-calculate item coords
714             autoexpand_item = null;
715           }, p.autoexpand);
716         }
717         else if (autoexpand_timer && autoexpand_item != id) {
718           clearTimeout(autoexpand_timer);
719           autoexpand_item = null;
720           autoexpand_timer = null;
721         }
3c309a 722
ec0f74 723         // check if this item is accepted as drop target
AM 724         if (p.check_droptarget(node)) {
725           if (highlight) {
726             id2dom(id).addClass('droptarget');
727             pos.on = true;
728           }
729           result = id;
730         }
731         else {
732           result = null;
733         }
734       }
735       else if (pos.on) {
736         id2dom(id).removeClass('droptarget');
737         pos.on = false;
738       }
739     }
3c309a 740
ec0f74 741     return result;
AM 742   }
3c309a 743 }
TB 744
745 // use event processing functions from Roundcube's rcube_event_engine
746 rcube_treelist_widget.prototype.addEventListener = rcube_event_engine.prototype.addEventListener;
747 rcube_treelist_widget.prototype.removeEventListener = rcube_event_engine.prototype.removeEventListener;
748 rcube_treelist_widget.prototype.triggerEvent = rcube_event_engine.prototype.triggerEvent;