comparison program/js/treelist.js @ 0:4681f974d28b

vanilla 1.3.3 distro, I hope
author Charlie Root
date Thu, 04 Jan 2018 15:52:31 -0500
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:4681f974d28b
1 /**
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 */
31
32
33 /**
34 * Roundcube Treelist widget class
35 * @constructor
36 */
37 function rcube_treelist_widget(node, p)
38 {
39 // apply some defaults to p
40 p = $.extend({
41 id_prefix: '',
42 autoexpand: 1000,
43 selectable: false,
44 scroll_delay: 500,
45 scroll_step: 5,
46 scroll_speed: 20,
47 save_state: false,
48 keyboard: true,
49 tabexit: true,
50 parent_focus: false,
51 check_droptarget: function(node) { return !node.virtual; }
52 }, p || {});
53
54 var container = $(node),
55 data = p.data || [],
56 indexbyid = {},
57 selection = null,
58 drag_active = false,
59 search_active = false,
60 last_search = '',
61 has_focus = false,
62 box_coords = {},
63 item_coords = [],
64 autoexpand_timer,
65 autoexpand_item,
66 body_scroll_top = 0,
67 list_scroll_top = 0,
68 scroll_timer,
69 searchfield,
70 tree_state,
71 ui_droppable,
72 ui_draggable,
73 draggable_opts,
74 droppable_opts,
75 list_id = (container.attr('id') || p.id_prefix || '0'),
76 me = this;
77
78
79 /////// export public members and methods
80
81 this.container = container;
82 this.expand = expand;
83 this.collapse = collapse;
84 this.select = select;
85 this.render = render;
86 this.reset = reset;
87 this.drag_start = drag_start;
88 this.drag_end = drag_end;
89 this.intersects = intersects;
90 this.droppable = droppable;
91 this.draggable = draggable;
92 this.update = update_node;
93 this.insert = insert;
94 this.remove = remove;
95 this.get_item = get_item;
96 this.get_node = get_node;
97 this.get_selection = get_selection;
98 this.is_search = is_search;
99 this.reset_search = reset_search;
100
101 /////// startup code (constructor)
102
103 // abort if node not found
104 if (!container.length)
105 return;
106
107 if (p.data)
108 index_data({ children:data });
109 // load data from DOM
110 else
111 update_data();
112
113 // scroll to the selected item
114 if (selection) {
115 scroll_to_node(id2dom(selection, true));
116 }
117
118 container.attr('role', 'tree')
119 .on('focusin', function(e) {
120 // TODO: only accept focus on virtual nodes from keyboard events
121 has_focus = true;
122 })
123 .on('focusout', function(e) {
124 has_focus = false;
125 })
126 // register click handlers on list
127 .on('click', 'div.treetoggle', function(e) {
128 toggle(dom2id($(this).parent()));
129 e.stopPropagation();
130 })
131 .on('click', 'li', function(e) {
132 // do not select record on checkbox/input click
133 if ($(e.target).is('input'))
134 return true;
135
136 var node = p.selectable ? indexbyid[dom2id($(this))] : null;
137 if (node && !node.virtual) {
138 select(node.id);
139 e.stopPropagation();
140 }
141 })
142 // mute clicks on virtual folder links (they need tabindex="0" in order to be selectable by keyboard)
143 .on('mousedown', 'a', function(e) {
144 var link = $(e.target), node = indexbyid[dom2id(link.closest('li'))];
145 if (node && node.virtual && !link.attr('href')) {
146 e.preventDefault();
147 e.stopPropagation();
148 return false;
149 }
150 });
151
152 // activate search function
153 if (p.searchbox) {
154 searchfield = $(p.searchbox).off('keyup.treelist').on('keyup.treelist', function(e) {
155 var key = rcube_event.get_keycode(e),
156 mod = rcube_event.get_modifier(e);
157
158 switch (key) {
159 case 9: // tab
160 break;
161
162 case 13: // enter
163 search(this.value, true);
164 return rcube_event.cancel(e);
165
166 case 27: // escape
167 reset_search();
168 break;
169
170 case 38: // arrow up
171 case 37: // left
172 case 39: // right
173 case 40: // arrow down
174 return; // ignore arrow keys
175
176 default:
177 search(this.value, false);
178 break;
179 }
180 }).attr('autocomplete', 'off');
181
182 // find the reset button for this search field
183 searchfield.parent().find('a.reset').off('click.treelist').on('click.treelist', function(e) {
184 reset_search();
185 return false;
186 })
187 }
188
189 $(document.body).on('keydown', keypress);
190
191 // catch focus when clicking the list container area
192 if (p.parent_focus) {
193 container.parent(':not(body)').click(function(e) {
194 // click on a checkbox does not catch the focus
195 if ($(e.target).is('input'))
196 return true;
197
198 if (!has_focus && selection) {
199 $(get_item(selection)).find(':focusable').first().focus();
200 }
201 else if (!has_focus) {
202 container.children('li:has(:focusable)').first().find(':focusable').first().focus();
203 }
204 });
205 }
206
207 /////// private methods
208
209 /**
210 * Collaps a the node with the given ID
211 */
212 function collapse(id, recursive, set)
213 {
214 var node;
215
216 if (node = indexbyid[id]) {
217 node.collapsed = typeof set == 'undefined' || set;
218 update_dom(node);
219
220 if (recursive && node.children) {
221 for (var i=0; i < node.children.length; i++) {
222 collapse(node.children[i].id, recursive, set);
223 }
224 }
225
226 me.triggerEvent(node.collapsed ? 'collapse' : 'expand', node);
227 save_state(id, node.collapsed);
228 }
229 }
230
231 /**
232 * Expand a the node with the given ID
233 */
234 function expand(id, recursive)
235 {
236 collapse(id, recursive, false);
237 }
238
239 /**
240 * Toggle collapsed state of a list node
241 */
242 function toggle(id, recursive)
243 {
244 var node;
245 if (node = indexbyid[id]) {
246 collapse(id, recursive, !node.collapsed);
247 }
248 }
249
250 /**
251 * Select a tree node by it's ID
252 */
253 function select(id)
254 {
255 // allow subscribes to prevent selection change
256 if (me.triggerEvent('beforeselect', indexbyid[id]) === false) {
257 return;
258 }
259
260 if (selection) {
261 id2dom(selection, true).removeClass('selected').removeAttr('aria-selected');
262 if (search_active)
263 id2dom(selection).removeClass('selected').removeAttr('aria-selected');
264 selection = null;
265 }
266
267 if (!id)
268 return;
269
270 var li = id2dom(id, true);
271 if (li.length) {
272 li.addClass('selected').attr('aria-selected', 'true');
273 selection = id;
274 // TODO: expand all parent nodes if collapsed
275
276 if (search_active)
277 id2dom(id).addClass('selected').attr('aria-selected', 'true');
278
279 scroll_to_node(li);
280 }
281
282 me.triggerEvent('select', indexbyid[id]);
283 }
284
285 /**
286 * Getter for the currently selected node ID
287 */
288 function get_selection()
289 {
290 return selection;
291 }
292
293 /**
294 * Return the DOM element of the list item with the given ID
295 */
296 function get_node(id)
297 {
298 return indexbyid[id];
299 }
300
301 /**
302 * Return the DOM element of the list item with the given ID
303 */
304 function get_item(id, real)
305 {
306 return id2dom(id, real).get(0);
307 }
308
309 /**
310 * Insert the given node
311 */
312 function insert(node, parent_id, sort)
313 {
314 var li, parent_li,
315 parent_node = parent_id ? indexbyid[parent_id] : null
316 search_ = search_active;
317
318 // ignore, already exists
319 if (indexbyid[node.id]) {
320 return;
321 }
322
323 // apply saved state
324 state = get_state(node.id, node.collapsed);
325 if (state !== undefined) {
326 node.collapsed = state;
327 }
328
329 // insert as child of an existing node
330 if (parent_node) {
331 node.level = parent_node.level + 1;
332 if (!parent_node.children)
333 parent_node.children = [];
334
335 search_active = false;
336 parent_node.children.push(node);
337 parent_li = id2dom(parent_id);
338
339 // re-render the entire subtree
340 if (parent_node.children.length == 1) {
341 render_node(parent_node, null, parent_li);
342 li = id2dom(node.id);
343 }
344 else {
345 // append new node to parent's child list
346 li = render_node(node, parent_li.children('ul').first());
347 }
348
349 // list is in search mode
350 if (search_) {
351 search_active = search_;
352
353 // add clone to current search results (top level)
354 if (!li.is(':visible')) {
355 $('<li>')
356 .attr('id', li.attr('id') + '--xsR')
357 .attr('class', li.attr('class'))
358 .addClass('searchresult__')
359 .append(li.children().first().clone(true, true))
360 .appendTo(container);
361 }
362 }
363 }
364 // insert at top level
365 else {
366 node.level = 0;
367 data.push(node);
368 li = render_node(node, container);
369 }
370
371 indexbyid[node.id] = node;
372
373 // set new reference to node.html after insert
374 // will otherwise vanish in Firefox 3.6
375 if (typeof node.html == 'object') {
376 indexbyid[node.id].html = id2dom(node.id, true).children();
377 }
378
379 if (sort) {
380 resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
381 }
382 }
383
384 /**
385 * Update properties of an existing node
386 */
387 function update_node(id, updates, sort)
388 {
389 var li, parent_ul, parent_node, old_parent,
390 node = indexbyid[id];
391
392 if (node) {
393 li = id2dom(id);
394 parent_ul = li.parent();
395
396 if (updates.id || updates.html || updates.children || updates.classes || updates.parent) {
397 if (updates.parent && (parent_node = indexbyid[updates.parent])) {
398 // remove reference from old parent's child list
399 if (parent_ul.closest('li').length && (old_parent = indexbyid[dom2id(parent_ul.closest('li'))])) {
400 old_parent.children = $.grep(old_parent.children, function(elem, i){ return elem.id != node.id; });
401 }
402
403 // append to new parent node
404 parent_ul = id2dom(updates.parent).children('ul').first();
405 if (!parent_node.children)
406 parent_node.children = [];
407 parent_node.children.push(node);
408 }
409 else if (updates.parent !== undefined) {
410 parent_ul = container;
411 }
412
413 $.extend(node, updates);
414 li = render_node(node, parent_ul, li);
415 }
416
417 if (node.id != id) {
418 delete indexbyid[id];
419 indexbyid[node.id] = node;
420 }
421
422 if (sort) {
423 resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
424 }
425 }
426 }
427
428 /**
429 * Helper method to sort the list of the given item
430 */
431 function resort_node(li, filter)
432 {
433 var first, sibling,
434 myid = li.get(0).id,
435 sortname = li.children().first().text().toUpperCase();
436
437 li.parent().children('li' + filter).each(function(i, elem) {
438 if (i == 0)
439 first = elem;
440 if (elem.id == myid) {
441 // skip
442 }
443 else if (elem.id != myid && sortname >= $(elem).children().first().text().toUpperCase()) {
444 sibling = elem;
445 }
446 else {
447 return false;
448 }
449 });
450
451 if (sibling) {
452 li.insertAfter(sibling);
453 }
454 else if (first && first.id != myid) {
455 li.insertBefore(first);
456 }
457
458 // reload data from dom
459 update_data();
460 }
461
462 /**
463 * Remove the item with the given ID
464 */
465 function remove(id)
466 {
467 var node, li, parent;
468
469 if (node = indexbyid[id]) {
470 li = id2dom(id, true);
471 parent = li.parent();
472 li.remove();
473
474 node.deleted = true;
475 delete indexbyid[id];
476
477 if (search_active) {
478 id2dom(id, false).remove();
479 }
480
481 // remove tree-toggle button and children list
482 if (!parent.children().length) {
483 parent.parent().find('div.treetoggle').remove();
484 parent.remove();
485 }
486
487 return true;
488 }
489
490 return false;
491 }
492
493 /**
494 * (Re-)read tree data from DOM
495 */
496 function update_data()
497 {
498 data = walk_list(container, 0);
499 }
500
501 /**
502 * Apply the 'collapsed' status of the data node to the corresponding DOM element(s)
503 */
504 function update_dom(node)
505 {
506 var li = id2dom(node.id, true);
507 li.attr('aria-expanded', node.collapsed ? 'false' : 'true');
508 li.children('ul').first()[(node.collapsed ? 'hide' : 'show')]();
509 li.children('div.treetoggle').removeClass('collapsed expanded').addClass(node.collapsed ? 'collapsed' : 'expanded');
510 me.triggerEvent('toggle', node);
511 }
512
513 /**
514 *
515 */
516 function reset(keep_content)
517 {
518 select('');
519
520 data = [];
521 indexbyid = {};
522 drag_active = false;
523
524 if (keep_content) {
525 if (draggable_opts) {
526 if (ui_draggable)
527 draggable('destroy');
528 draggable(draggable_opts);
529 }
530
531 if (droppable_opts) {
532 if (ui_droppable)
533 droppable('destroy');
534 droppable(droppable_opts);
535 }
536
537 update_data();
538 }
539 else {
540 container.html('');
541 }
542
543 reset_search();
544 }
545
546 /**
547 *
548 */
549 function search(q, enter)
550 {
551 q = String(q).toLowerCase();
552
553 if (!q.length)
554 return reset_search();
555 else if (q == last_search && !enter)
556 return 0;
557
558 var hits = [];
559 var search_tree = function(items) {
560 $.each(items, function(i, node) {
561 var li, sli;
562 if (!node.virtual && !node.deleted && String(node.text).toLowerCase().indexOf(q) >= 0 && hits.indexOf(node.id) < 0) {
563 li = id2dom(node.id);
564
565 // skip already filtered nodes
566 if (li.data('filtered'))
567 return;
568
569 sli = $('<li>')
570 .attr('id', li.attr('id') + '--xsR')
571 .attr('class', li.attr('class'))
572 .addClass('searchresult__')
573 // append all elements like links and inputs, but not sub-trees
574 .append(li.children(':not(div.treetoggle,ul)').clone(true, true))
575 .appendTo(container);
576 hits.push(node.id);
577 }
578
579 if (node.children && node.children.length) {
580 search_tree(node.children);
581 }
582 });
583 };
584
585 // reset old search results
586 if (search_active) {
587 $(container).children('li.searchresult__').remove();
588 search_active = false;
589 }
590
591 // hide all list items
592 $(container).children('li').hide().removeClass('selected');
593
594 // search recursively in tree (to keep sorting order)
595 search_tree(data);
596 search_active = true;
597
598 me.triggerEvent('search', { query: q, last: last_search, count: hits.length, ids: hits, execute: enter||false });
599
600 last_search = q;
601
602 return hits.count;
603 }
604
605 /**
606 *
607 */
608 function reset_search()
609 {
610 if (searchfield)
611 searchfield.val('');
612
613 $(container).children('li.searchresult__').remove();
614 $(container).children('li').filter(function() { return !$(this).data('filtered'); }).show();
615
616 search_active = false;
617
618 me.triggerEvent('search', { query: false, last: last_search });
619 last_search = '';
620
621 if (selection)
622 select(selection);
623 }
624
625 /**
626 *
627 */
628 function is_search()
629 {
630 return search_active;
631 }
632
633 /**
634 * Render the tree list from the internal data structure
635 */
636 function render()
637 {
638 if (me.triggerEvent('renderBefore', data) === false)
639 return;
640
641 // remove all child nodes
642 container.html('');
643
644 // render child nodes
645 for (var i=0; i < data.length; i++) {
646 data[i].level = 0;
647 render_node(data[i], container);
648 }
649
650 me.triggerEvent('renderAfter', container);
651 }
652
653 /**
654 * Render a specific node into the DOM list
655 */
656 function render_node(node, parent, replace)
657 {
658 if (node.deleted)
659 return;
660
661 var li = $('<li>')
662 .attr('id', p.id_prefix + (p.id_encode ? p.id_encode(node.id) : node.id))
663 .attr('role', 'treeitem')
664 .addClass((node.classes || []).join(' '))
665 .data('id', node.id);
666
667 if (replace) {
668 replace.replaceWith(li);
669 if (parent)
670 li.appendTo(parent);
671 }
672 else
673 li.appendTo(parent);
674
675 if (typeof node.html == 'string')
676 li.html(node.html);
677 else if (typeof node.html == 'object')
678 li.append(node.html);
679
680 if (!node.text)
681 node.text = li.children().first().text();
682
683 if (node.virtual)
684 li.addClass('virtual');
685 if (node.id == selection)
686 li.addClass('selected');
687
688 // add child list and toggle icon
689 if (node.children && node.children.length) {
690 li.attr('aria-expanded', node.collapsed ? 'false' : 'true');
691 $('<div class="treetoggle '+(node.collapsed ? 'collapsed' : 'expanded') + '">&nbsp;</div>').appendTo(li);
692 var ul = $('<ul>').appendTo(li).attr('class', node.childlistclass).attr('role', 'group');
693 if (node.collapsed)
694 ul.hide();
695
696 for (var i=0; i < node.children.length; i++) {
697 node.children[i].level = node.level + 1;
698 render_node(node.children[i], ul);
699 }
700 }
701
702 return li;
703 }
704
705 /**
706 * Recursively walk the DOM tree and build an internal data structure
707 * representing the skeleton of this tree list.
708 */
709 function walk_list(ul, level)
710 {
711 var result = [];
712 ul.children('li').each(function(i,e){
713 var state, li = $(e), sublist = li.children('ul');
714 var node = {
715 id: dom2id(li),
716 classes: String(li.attr('class')).split(' '),
717 virtual: li.hasClass('virtual'),
718 level: level,
719 html: li.children().first().get(0).outerHTML,
720 text: li.children().first().text(),
721 children: walk_list(sublist, level+1)
722 }
723
724 if (sublist.length) {
725 node.childlistclass = sublist.attr('class');
726 }
727 if (node.children.length) {
728 if (node.collapsed === undefined)
729 node.collapsed = sublist.css('display') == 'none';
730
731 // apply saved state
732 state = get_state(node.id, node.collapsed);
733 if (state !== undefined) {
734 node.collapsed = state;
735 sublist[(state?'hide':'show')]();
736 }
737
738 if (!li.children('div.treetoggle').length)
739 $('<div class="treetoggle '+(node.collapsed ? 'collapsed' : 'expanded') + '">&nbsp;</div>').appendTo(li);
740
741 li.attr('aria-expanded', node.collapsed ? 'false' : 'true');
742 }
743 if (li.hasClass('selected')) {
744 li.attr('aria-selected', 'true');
745 selection = node.id;
746 }
747
748 li.data('id', node.id);
749
750 // declare list item as treeitem
751 li.attr('role', 'treeitem').attr('aria-level', node.level+1);
752
753 // allow virtual nodes to receive focus
754 if (node.virtual) {
755 li.children('a:first').attr('tabindex', '0');
756 }
757
758 result.push(node);
759 indexbyid[node.id] = node;
760 });
761
762 ul.attr('role', level == 0 ? 'tree' : 'group');
763
764 return result;
765 }
766
767 /**
768 * Recursively walk the data tree and index nodes by their ID
769 */
770 function index_data(node)
771 {
772 if (node.id) {
773 indexbyid[node.id] = node;
774 }
775 for (var c=0; node.children && c < node.children.length; c++) {
776 index_data(node.children[c]);
777 }
778 }
779
780 /**
781 * Get the (stripped) node ID from the given DOM element
782 */
783 function dom2id(li)
784 {
785 var domid = String(li.attr('id')).replace(new RegExp('^' + (p.id_prefix) || '%'), '').replace(/--xsR$/, '');
786 return p.id_decode ? p.id_decode(domid) : domid;
787 }
788
789 /**
790 * Get the <li> element for the given node ID
791 */
792 function id2dom(id, real)
793 {
794 var domid = p.id_encode ? p.id_encode(id) : id,
795 suffix = search_active && !real ? '--xsR' : '';
796
797 return $('#' + p.id_prefix + domid + suffix, container);
798 }
799
800 /**
801 * Scroll the parent container to make the given list item visible
802 */
803 function scroll_to_node(li)
804 {
805 var scroller = container.parent(),
806 current_offset = scroller.scrollTop(),
807 rel_offset = li.offset().top - scroller.offset().top;
808
809 if (rel_offset < 0 || rel_offset + li.height() > scroller.height())
810 scroller.scrollTop(rel_offset + current_offset);
811 }
812
813 /**
814 * Save node collapse state to localStorage
815 */
816 function save_state(id, collapsed)
817 {
818 if (p.save_state && window.rcmail) {
819 var key = 'treelist-' + list_id;
820 if (!tree_state) {
821 tree_state = rcmail.local_storage_get_item(key, {});
822 }
823
824 if (tree_state[id] != collapsed) {
825 tree_state[id] = collapsed;
826 rcmail.local_storage_set_item(key, tree_state);
827 }
828 }
829 }
830
831 /**
832 * Read node collapse state from localStorage
833 */
834 function get_state(id)
835 {
836 if (p.save_state && window.rcmail) {
837 if (!tree_state) {
838 tree_state = rcmail.local_storage_get_item('treelist-' + list_id, {});
839 }
840 return tree_state[id];
841 }
842
843 return undefined;
844 }
845
846 /**
847 * Handler for keyboard events on treelist
848 */
849 function keypress(e)
850 {
851 var target = e.target || {},
852 keyCode = rcube_event.get_keycode(e);
853
854 if (!has_focus || target.nodeName == 'INPUT' && keyCode != 38 && keyCode != 40 || target.nodeName == 'TEXTAREA' || target.nodeName == 'SELECT')
855 return true;
856
857 switch (keyCode) {
858 case 38:
859 case 40:
860 case 63232: // 'up', in safari keypress
861 case 63233: // 'down', in safari keypress
862 var li = p.keyboard ? container.find(':focus').closest('li') : [];
863 if (li.length) {
864 focus_next(li, (mod = keyCode == 38 || keyCode == 63232 ? -1 : 1));
865 }
866 return rcube_event.cancel(e);
867
868 case 37: // Left arrow key
869 case 39: // Right arrow key
870 var id, node, li = container.find(':focus').closest('li');
871 if (li.length) {
872 id = dom2id(li);
873 node = indexbyid[id];
874 if (node && node.children.length && node.collapsed != (keyCode == 37))
875 toggle(id, rcube_event.get_modifier(e) == SHIFT_KEY); // toggle subtree
876 }
877 return false;
878
879 case 9: // Tab
880 if (p.keyboard && p.tabexit) {
881 // jump to last/first item to move focus away from the treelist widget by tab
882 var limit = rcube_event.get_modifier(e) == SHIFT_KEY ? 'first' : 'last';
883 focus_noscroll(container.find('li[role=treeitem]:has(a)')[limit]().find('a:'+limit));
884 }
885 break;
886 }
887
888 return true;
889 }
890
891 function focus_next(li, dir, from_child)
892 {
893 var mod = dir < 0 ? 'prev' : 'next',
894 next = li[mod](), limit, parent;
895
896 if (dir > 0 && !from_child && li.children('ul[role=group]:visible').length) {
897 li.children('ul').children('li:first').find('a:first').focus();
898 }
899 else if (dir < 0 && !from_child && next.children('ul[role=group]:visible').length) {
900 next.children('ul').children('li:last').find('a:first').focus();
901 }
902 else if (next.length && next.find('a:first').focus().length) {
903 // focused
904 }
905 else {
906 parent = li.parent().closest('li[role=treeitem]');
907 if (parent.length)
908 if (dir < 0) {
909 parent.find('a:first').focus();
910 }
911 else {
912 focus_next(parent, dir, true);
913 }
914 }
915 }
916
917 /**
918 * Focus the given element without scrolling the list container
919 */
920 function focus_noscroll(elem)
921 {
922 if (elem.length) {
923 var frame = container.parent().get(0) || { scrollTop:0 },
924 y = frame.scrollTop || frame.scrollY;
925 elem.focus();
926 frame.scrollTop = y;
927 }
928 }
929
930
931 ///// drag & drop support
932
933 /**
934 * When dragging starts, compute absolute bounding boxes of the list and it's items
935 * for faster comparisons while mouse is moving
936 */
937 function drag_start(force)
938 {
939 if (!force && drag_active)
940 return;
941
942 drag_active = true;
943
944 var li, item, height,
945 pos = container.offset();
946
947 body_scroll_top = bw.ie ? 0 : window.pageYOffset;
948 list_scroll_top = container.parent().scrollTop();
949 pos.top += list_scroll_top;
950
951 box_coords = {
952 x1: pos.left,
953 y1: pos.top,
954 x2: pos.left + container.width(),
955 y2: pos.top + container.height()
956 };
957
958 item_coords = [];
959 for (var id in indexbyid) {
960 li = id2dom(id);
961 item = li.children().first().get(0);
962 if (item && (height = item.offsetHeight)) {
963 pos = $(item).offset();
964 pos.top += list_scroll_top;
965 item_coords[id] = {
966 x1: pos.left,
967 y1: pos.top,
968 x2: pos.left + item.offsetWidth,
969 y2: pos.top + height,
970 on: id == autoexpand_item
971 };
972 }
973 }
974
975 // enable auto-scrolling of list container
976 if (container.height() > container.parent().height()) {
977 container.parent()
978 .mousemove(function(e) {
979 var scroll = 0,
980 mouse = rcube_event.get_mouse_pos(e);
981 mouse.y -= container.parent().offset().top;
982
983 if (mouse.y < 25 && list_scroll_top > 0) {
984 scroll = -1; // up
985 }
986 else if (mouse.y > container.parent().height() - 25) {
987 scroll = 1; // down
988 }
989
990 if (drag_active && scroll != 0) {
991 if (!scroll_timer)
992 scroll_timer = window.setTimeout(function(){ drag_scroll(scroll); }, p.scroll_delay);
993 }
994 else if (scroll_timer) {
995 window.clearTimeout(scroll_timer);
996 scroll_timer = null;
997 }
998 })
999 .mouseleave(function() {
1000 if (scroll_timer) {
1001 window.clearTimeout(scroll_timer);
1002 scroll_timer = null;
1003 }
1004 });
1005 }
1006 }
1007
1008 /**
1009 * Signal that dragging has stopped
1010 */
1011 function drag_end()
1012 {
1013 if (!drag_active)
1014 return;
1015
1016 drag_active = false;
1017 scroll_timer = null;
1018
1019 if (autoexpand_timer) {
1020 clearTimeout(autoexpand_timer);
1021 autoexpand_timer = null;
1022 autoexpand_item = null;
1023 }
1024
1025 $('li.droptarget', container).removeClass('droptarget');
1026 }
1027
1028 /**
1029 * Scroll list container in the given direction
1030 */
1031 function drag_scroll(dir)
1032 {
1033 if (!drag_active)
1034 return;
1035
1036 var old_top = list_scroll_top;
1037 container.parent().get(0).scrollTop += p.scroll_step * dir;
1038 list_scroll_top = container.parent().scrollTop();
1039 scroll_timer = null;
1040
1041 if (list_scroll_top != old_top)
1042 scroll_timer = window.setTimeout(function(){ drag_scroll(dir); }, p.scroll_speed);
1043 }
1044
1045 /**
1046 * Determine if the given mouse coords intersect the list and one of its items
1047 */
1048 function intersects(mouse, highlight)
1049 {
1050 // offsets to compensate for scrolling while dragging a message
1051 var boffset = bw.ie ? -document.documentElement.scrollTop : body_scroll_top,
1052 moffset = container.parent().scrollTop(),
1053 result = null;
1054
1055 mouse.top = mouse.y + moffset - boffset;
1056
1057 // no intersection with list bounding box
1058 if (mouse.x < box_coords.x1 || mouse.x >= box_coords.x2 || mouse.top < box_coords.y1 || mouse.top >= box_coords.y2) {
1059 // TODO: optimize performance for this operation
1060 if (highlight)
1061 $('li.droptarget', container).removeClass('droptarget');
1062 return result;
1063 }
1064
1065 // check intersection with visible list items
1066 var id, pos, node;
1067 for (id in item_coords) {
1068 pos = item_coords[id];
1069 if (mouse.x >= pos.x1 && mouse.x < pos.x2 && mouse.top >= pos.y1 && mouse.top < pos.y2) {
1070 node = indexbyid[id];
1071
1072 // if the folder is collapsed, expand it after the configured time
1073 if (node.children && node.children.length && node.collapsed && p.autoexpand && autoexpand_item != id) {
1074 if (autoexpand_timer)
1075 clearTimeout(autoexpand_timer);
1076
1077 autoexpand_item = id;
1078 autoexpand_timer = setTimeout(function() {
1079 expand(autoexpand_item);
1080 drag_start(true); // re-calculate item coords
1081 autoexpand_item = null;
1082 if (ui_droppable)
1083 $.ui.ddmanager.prepareOffsets($.ui.ddmanager.current, null);
1084 }, p.autoexpand);
1085 }
1086 else if (autoexpand_timer && autoexpand_item != id) {
1087 clearTimeout(autoexpand_timer);
1088 autoexpand_item = null;
1089 autoexpand_timer = null;
1090 }
1091
1092 // check if this item is accepted as drop target
1093 if (p.check_droptarget(node)) {
1094 if (highlight) {
1095 id2dom(id).addClass('droptarget');
1096 pos.on = true;
1097 }
1098 result = id;
1099 }
1100 else {
1101 result = null;
1102 }
1103 }
1104 else if (pos.on) {
1105 id2dom(id).removeClass('droptarget');
1106 pos.on = false;
1107 }
1108 }
1109
1110 return result;
1111 }
1112
1113 /**
1114 * Wrapper for jQuery.UI.droppable() activation on this widget
1115 *
1116 * @param object Options as passed to regular .droppable() function
1117 */
1118 function droppable(opts)
1119 {
1120 if (!opts) opts = {};
1121
1122 if ($.type(opts) == 'string') {
1123 if (opts == 'destroy') {
1124 ui_droppable = null;
1125 }
1126 $('li:not(.virtual)', container).droppable(opts);
1127 return this;
1128 }
1129
1130 droppable_opts = opts;
1131
1132 var my_opts = $.extend({
1133 greedy: true,
1134 tolerance: 'pointer',
1135 hoverClass: 'droptarget',
1136 addClasses: false
1137 }, opts);
1138
1139 my_opts.activate = function(e, ui) {
1140 drag_start();
1141 ui_droppable = ui;
1142 if (opts.activate)
1143 opts.activate(e, ui);
1144 };
1145
1146 my_opts.deactivate = function(e, ui) {
1147 drag_end();
1148 ui_droppable = null;
1149 if (opts.deactivate)
1150 opts.deactivate(e, ui);
1151 };
1152
1153 my_opts.over = function(e, ui) {
1154 intersects(rcube_event.get_mouse_pos(e), false);
1155 if (opts.over)
1156 opts.over(e, ui);
1157 };
1158
1159 $('li:not(.virtual)', container).droppable(my_opts);
1160
1161 return this;
1162 }
1163
1164 /**
1165 * Wrapper for jQuery.UI.draggable() activation on this widget
1166 *
1167 * @param object Options as passed to regular .draggable() function
1168 */
1169 function draggable(opts)
1170 {
1171 if (!opts) opts = {};
1172
1173 if ($.type(opts) == 'string') {
1174 if (opts == 'destroy') {
1175 ui_draggable = null;
1176 }
1177 $('li:not(.virtual)', container).draggable(opts);
1178 return this;
1179 }
1180
1181 draggable_opts = opts;
1182
1183 var my_opts = $.extend({
1184 appendTo: 'body',
1185 revert: 'invalid',
1186 iframeFix: true,
1187 addClasses: false,
1188 cursorAt: {left: -20, top: 5},
1189 create: function(e, ui) { ui_draggable = ui; },
1190 helper: function(e) {
1191 return $('<div>').attr('id', 'rcmdraglayer')
1192 .text($.trim($(e.target).first().text()));
1193 }
1194 }, opts);
1195
1196 $('li:not(.virtual)', container).draggable(my_opts);
1197
1198 return this;
1199 }
1200 }
1201
1202 // use event processing functions from Roundcube's rcube_event_engine
1203 rcube_treelist_widget.prototype.addEventListener = rcube_event_engine.prototype.addEventListener;
1204 rcube_treelist_widget.prototype.removeEventListener = rcube_event_engine.prototype.removeEventListener;
1205 rcube_treelist_widget.prototype.triggerEvent = rcube_event_engine.prototype.triggerEvent;