Mercurial > hg > rc2
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') + '"> </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') + '"> </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; |