From 3c309af0663f1673f921682c6ae3f673426e1397 Mon Sep 17 00:00:00 2001
From: Thomas Bruederli <thomas@roundcube.net>
Date: Thu, 31 Jan 2013 07:49:35 -0500
Subject: [PATCH] - Refactored the hierarchical mailboxlist control into a separate widget class - Build address book directories list as hierarchical list - Make address book groups collapsible using the new new treelist widget - Use encoded identifiers for address book directory list items

---
 skins/classic/templates/addressbook.html |    3 
 program/js/treelist.js                   |  425 ++++++++++++++++++++++++++++++++
 skins/larry/templates/addressbook.html   |    2 
 skins/classic/addressbook.css            |   39 +-
 program/include/rcmail.php               |   14 
 program/steps/addressbook/func.inc       |   28 +
 skins/larry/addressbook.css              |   21 +
 program/js/app.js                        |  206 +++++----------
 8 files changed, 567 insertions(+), 171 deletions(-)

diff --git a/program/include/rcmail.php b/program/include/rcmail.php
index 5f2a217..70dba41 100644
--- a/program/include/rcmail.php
+++ b/program/include/rcmail.php
@@ -1406,6 +1406,7 @@
             $js_mailboxlist = array();
             $out = html::tag('ul', $attrib, $rcmail->render_folder_tree_html($a_mailboxes, $mbox_name, $js_mailboxlist, $attrib), html::$common_attrib);
 
+            $rcmail->output->include_script('treelist.js');
             $rcmail->output->add_gui_object('mailboxlist', $attrib['id']);
             $rcmail->output->set_env('mailboxes', $js_mailboxlist);
             $rcmail->output->set_env('unreadwrap', $attrib['unreadwrap']);
@@ -1584,14 +1585,13 @@
                 'id' => "rcmli".$folder_id,
                 'class' => join(' ', $classes),
                 'noclose' => true),
-                html::a($link_attrib, $html_name) .
-                (!empty($folder['folders']) ? html::div(array(
-                    'class' => ($is_collapsed ? 'collapsed' : 'expanded'),
-                    'style' => "position:absolute",
-                    'onclick' => sprintf("%s.command('collapse-folder', '%s')", rcmail_output::JS_OBJECT_NAME, $js_name)
-                ), '&nbsp;') : ''));
+                html::a($link_attrib, $html_name));
 
-            $jslist[$folder_id] = array(
+            if (!empty($folder['folders'])) {
+                $out .= html::div('treetoggle ' . ($is_collapsed ? 'collapsed' : 'expanded'), '&nbsp;');
+            }
+
+            $jslist[$folder['id']] = array(
                 'id'      => $folder['id'],
                 'name'    => $foldername,
                 'virtual' => $folder['virtual']
diff --git a/program/js/app.js b/program/js/app.js
index 6d5bdfe..dbb65ee 100644
--- a/program/js/app.js
+++ b/program/js/app.js
@@ -4,7 +4,7 @@
  |                                                                       |
  | This file is part of the Roundcube Webmail client                     |
  | Copyright (C) 2005-2013, The Roundcube Dev Team                       |
- | Copyright (C) 2011-2012, Kolab Systems AG                             |
+ | Copyright (C) 2011-2013, Kolab Systems AG                             |
  |                                                                       |
  | Licensed under the GNU General Public License version 3 or            |
  | any later version with exceptions for skins & plugins.                |
@@ -459,8 +459,21 @@
       this.display_message(this.pending_message[0], this.pending_message[1], this.pending_message[2]);
 
     // map implicit containers
-    if (this.gui_objects.folderlist)
+    if (this.gui_objects.folderlist) {
       this.gui_containers.foldertray = $(this.gui_objects.folderlist);
+
+      // init treelist widget
+      if (window.rcube_treelist_widget) {
+        this.treelist = new rcube_treelist_widget(this.gui_objects.folderlist, {
+          id_prefix: 'rcmli',
+          id_encode: this.html_identifier_encode,
+          id_decode: this.html_identifier_decode,
+          check_droptarget: function(node){ return !node.virtual && ref.check_droptarget(node.id) }
+        });
+        this.treelist.addEventListener('collapse', function(node){ ref.folder_collapsed(node) });
+        this.treelist.addEventListener('expand', function(node){ ref.folder_collapsed(node) });
+      }
+    }
 
     // activate html5 file drop feature (if browser supports it and if configured)
     if (this.gui_objects.filedrop && this.env.filedrop && ((window.XMLHttpRequest && XMLHttpRequest.prototype && XMLHttpRequest.prototype.sendAsBinary) || window.FormData)) {
@@ -1263,11 +1276,12 @@
 
   this.html_identifier = function(str, encode)
   {
-    str = String(str);
-    if (encode)
-      return Base64.encode(str).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
-    else
-      return str.replace(this.identifier_expr, '_');
+    return encode ? this.html_identifier_encode(str) : String(str).replace(this.identifier_expr, '_');
+  };
+
+  this.html_identifier_encode = function(str)
+  {
+    return Base64.encode(String(str)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
   };
 
   this.html_identifier_decode = function(str)
@@ -1320,29 +1334,9 @@
     if (this.preview_read_timer)
       clearTimeout(this.preview_read_timer);
 
-    // save folderlist and folders location/sizes for droptarget calculation in drag_move()
-    if (this.gui_objects.folderlist && model) {
-      this.initialBodyScrollTop = bw.ie ? 0 : window.pageYOffset;
-      this.initialListScrollTop = this.gui_objects.folderlist.parentNode.scrollTop;
-
-      var k, li, height,
-        list = $(this.gui_objects.folderlist);
-        pos = list.offset();
-
-      this.env.folderlist_coords = { x1:pos.left, y1:pos.top, x2:pos.left + list.width(), y2:pos.top + list.height() };
-
-      this.env.folder_coords = [];
-      for (k in model) {
-        if (li = this.get_folder_li(k)) {
-          // only visible folders
-          if (height = li.firstChild.offsetHeight) {
-            pos = $(li.firstChild).offset();
-            this.env.folder_coords[k] = { x1:pos.left, y1:pos.top,
-              x2:pos.left + li.firstChild.offsetWidth, y2:pos.top + height, on:0 };
-          }
-        }
-      }
-    }
+    // prepare treelist widget for dragging interactions
+    if (this.treelist)
+      this.treelist.drag_start();
   };
 
   this.drag_end = function(e)
@@ -1350,87 +1344,28 @@
     this.drag_active = false;
     this.env.last_folder_target = null;
 
-    if (this.folder_auto_timer) {
-      clearTimeout(this.folder_auto_timer);
-      this.folder_auto_timer = null;
-      this.folder_auto_expand = null;
-    }
-
-    // over the folders
-    if (this.gui_objects.folderlist && this.env.folder_coords) {
-      for (var k in this.env.folder_coords) {
-        if (this.env.folder_coords[k].on)
-          $(this.get_folder_li(k)).removeClass('droptarget');
-      }
-    }
+    if (this.treelist)
+      this.treelist.drag_end();
   };
 
   this.drag_move = function(e)
   {
-    if (this.gui_objects.folderlist && this.env.folder_coords) {
-      var k, li, div, check, oldclass,
+    if (this.gui_objects.folderlist) {
+      var drag_target, oldclass,
         layerclass = 'draglayernormal',
-        mouse = rcube_event.get_mouse_pos(e),
-        pos = this.env.folderlist_coords,
-        // offsets to compensate for scrolling while dragging a message
-        boffset = bw.ie ? -document.documentElement.scrollTop : this.initialBodyScrollTop,
-        moffset = this.initialListScrollTop-this.gui_objects.folderlist.parentNode.scrollTop;
+        mouse = rcube_event.get_mouse_pos(e);
 
       if (this.contact_list && this.contact_list.draglayer)
         oldclass = this.contact_list.draglayer.attr('class');
 
-      mouse.y += -moffset-boffset;
-
-      // if mouse pointer is outside of folderlist
-      if (mouse.x < pos.x1 || mouse.x >= pos.x2 || mouse.y < pos.y1 || mouse.y >= pos.y2) {
-        if (this.env.last_folder_target) {
-          $(this.get_folder_li(this.env.last_folder_target)).removeClass('droptarget');
-          this.env.folder_coords[this.env.last_folder_target].on = 0;
-          this.env.last_folder_target = null;
-        }
-        if (layerclass != oldclass && this.contact_list && this.contact_list.draglayer)
-          this.contact_list.draglayer.attr('class', layerclass);
-        return;
+      // mouse intersects a valid drop target on the treelist
+      if (this.treelist && (drag_target = this.treelist.intersects(mouse, true))) {
+        this.env.last_folder_target = drag_target;
+        layerclass = 'draglayer' + (this.check_droptarget(drag_target) > 1 ? 'copy' : 'normal');
       }
-
-      // over the folders
-      for (k in this.env.folder_coords) {
-        pos = this.env.folder_coords[k];
-        if (mouse.x >= pos.x1 && mouse.x < pos.x2 && mouse.y >= pos.y1 && mouse.y < pos.y2) {
-          if (check = this.check_droptarget(k)) {
-            li = this.get_folder_li(k);
-            div = $(li.getElementsByTagName('div')[0]);
-
-            // if the folder is collapsed, expand it after 1sec and restart the drag & drop process.
-            if (div.hasClass('collapsed')) {
-              if (this.folder_auto_timer)
-                clearTimeout(this.folder_auto_timer);
-
-              this.folder_auto_expand = this.env.mailboxes[k].id;
-              this.folder_auto_timer = setTimeout(function() {
-                rcmail.command('collapse-folder', rcmail.folder_auto_expand);
-                rcmail.drag_start(null);
-              }, 1000);
-            }
-            else if (this.folder_auto_timer) {
-              clearTimeout(this.folder_auto_timer);
-              this.folder_auto_timer = null;
-              this.folder_auto_expand = null;
-            }
-
-            $(li).addClass('droptarget');
-            this.env.folder_coords[k].on = 1;
-            this.env.last_folder_target = k;
-            layerclass = 'draglayer' + (check > 1 ? 'copy' : 'normal');
-          }
-          // Clear target, otherwise drag end will trigger move into last valid droptarget
-          else
-            this.env.last_folder_target = null;
-        }
-        else if (pos.on) {
-          $(this.get_folder_li(k)).removeClass('droptarget');
-          this.env.folder_coords[k].on = 0;
-        }
+      else {
+        // Clear target, otherwise drag end will trigger move into last valid droptarget
+        this.env.last_folder_target = null;
       }
 
       if (layerclass != oldclass && this.contact_list && this.contact_list.draglayer)
@@ -1440,40 +1375,33 @@
 
   this.collapse_folder = function(name)
   {
-    var li = this.get_folder_li(name, '', true),
-      div = $('div:first', li),
-      ul = $('ul:first', li);
+    if (this.treelist)
+      this.treelist.toggle(name);
+  };
 
-    if (div.hasClass('collapsed')) {
-      ul.show();
-      div.removeClass('collapsed').addClass('expanded');
-      var reg = new RegExp('&'+urlencode(name)+'&');
-      this.env.collapsed_folders = this.env.collapsed_folders.replace(reg, '');
-    }
-    else if (div.hasClass('expanded')) {
-      ul.hide();
-      div.removeClass('expanded').addClass('collapsed');
-      this.env.collapsed_folders = this.env.collapsed_folders+'&'+urlencode(name)+'&';
+  this.folder_collapsed = function(node)
+  {
+    var prefname = this.env.task == 'addressbook' ? 'collapsed_abooks' : 'collapsed_folders';
+
+    if (node.collapsed) {
+      this.env[prefname] = this.env[prefname] + '&'+urlencode(node.id)+'&';
 
       // select the folder if one of its childs is currently selected
       // don't select if it's virtual (#1488346)
-      if (this.env.mailbox.indexOf(name + this.env.delimiter) == 0 && !$(li).hasClass('virtual'))
+      if (this.env.mailbox && this.env.mailbox.indexOf(name + this.env.delimiter) == 0 && !node.virtual)
         this.command('list', name);
     }
-    else
-      return;
-
-    // Work around a bug in IE6 and IE7, see #1485309
-    if (bw.ie6 || bw.ie7) {
-      var siblings = li.nextSibling ? li.nextSibling.getElementsByTagName('ul') : null;
-      if (siblings && siblings.length && (li = siblings[0]) && li.style && li.style.display != 'none') {
-        li.style.display = 'none';
-        li.style.display = '';
-      }
+    else {
+      var reg = new RegExp('&'+urlencode(node.id)+'&');
+      this.env[prefname] = this.env[prefname].replace(reg, '');
     }
 
-    this.command('save-pref', { name: 'collapsed_folders', value: this.env.collapsed_folders });
-    this.set_unread_count_display(name, false);
+    if (!this.drag_active) {
+      this.command('save-pref', { name: prefname, value: this.env[prefname] });
+
+      if (this.env.unread_counts)
+        this.set_unread_count_display(node.id, false);
+    }
   };
 
   this.doc_mouse_up = function(e)
@@ -1498,9 +1426,9 @@
     if (this.drag_active && model && this.env.last_folder_target) {
       var target = model[this.env.last_folder_target];
 
-      $(this.get_folder_li(this.env.last_folder_target)).removeClass('droptarget');
       this.env.last_folder_target = null;
       list.draglayer.hide();
+      this.drag_end(e);
 
       if (!this.drag_menu(e, target))
         this.command('moveto', target);
@@ -4164,7 +4092,7 @@
     else if (!this.env.search_request)
       folder = group ? 'G'+src+group : src;
 
-    this.select_folder(folder);
+    this.select_folder(folder, '', true);
 
     this.env.source = src;
     this.env.group = group;
@@ -4468,7 +4396,7 @@
       this.name_input.bind('keydown', function(e){ return rcmail.add_input_keydown(e); });
       this.env.group_renaming = true;
 
-      var link, li = this.get_folder_li(this.env.source+this.env.group, 'rcmliG');
+      var link, li = this.get_folder_li('G'+this.env.source+this.env.group,'',true);
       if (li && (link = li.firstChild)) {
         $(link).hide().before(this.name_input);
       }
@@ -4489,7 +4417,7 @@
   this.remove_group_item = function(prop)
   {
     var li, key = 'G'+prop.source+prop.id;
-    if ((li = this.get_folder_li(key))) {
+    if ((li = this.get_folder_li(key,'',true))) {
       this.triggerEvent('group_delete', { source:prop.source, id:prop.id, li:li });
 
       li.parentNode.removeChild(li);
@@ -4511,7 +4439,7 @@
       this.name_input.bind('keydown', function(e){ return rcmail.add_input_keydown(e); });
       this.name_input_li = $('<li>').addClass(type).append(this.name_input);
 
-      var li = type == 'contactsearch' ? $('li:last', this.gui_objects.folderlist) : this.get_folder_li(this.env.source);
+      var li = type == 'contactsearch' ? $('li:last', this.gui_objects.folderlist) : $('ul.groups li:last', this.get_folder_li(this.env.source,'',true));
       this.name_input_li.insertAfter(li);
     }
 
@@ -4612,7 +4540,7 @@
     this.reset_add_input();
 
     var key = 'G'+prop.source+prop.id,
-      li = this.get_folder_li(key),
+      li = this.get_folder_li(key,'',true),
       link;
 
     // group ID has changed, replace link node and identifiers
@@ -4651,8 +4579,8 @@
   this.add_contact_group_row = function(prop, li, reloc)
   {
     var row, name = prop.name.toUpperCase(),
-      sibling = this.get_folder_li(prop.source),
-      prefix = 'rcmliG' + this.html_identifier(prop.source);
+      sibling = this.get_folder_li(prop.source,'',true),
+      prefix = 'rcmli' + this.html_identifier('G'+prop.source, true);
 
     // When renaming groups, we need to remove it from DOM and insert it in the proper place
     if (reloc) {
@@ -4901,12 +4829,12 @@
         .attr('rel', id)
         .click(function() { return rcmail.command('listsearch', id, this); })
         .html(name),
-      li = $('<li>').attr({id: 'rcmli' + this.html_identifier(key), 'class': 'contactsearch'})
+      li = $('<li>').attr({ id:'rcmli' + this.html_identifier(key,true), 'class':'contactsearch' })
         .append(link),
       prop = {name:name, id:id, li:li[0]};
 
     this.add_saved_search_row(prop, li);
-    this.select_folder('S'+id);
+    this.select_folder(key,'',true);
     this.enable_command('search-delete', true);
     this.env.search_id = id;
 
@@ -4960,7 +4888,7 @@
   this.remove_search_item = function(id)
   {
     var li, key = 'S'+id;
-    if ((li = this.get_folder_li(key))) {
+    if ((li = this.get_folder_li(key,'',true))) {
       this.triggerEvent('search_delete', { id:id, li:li });
 
       li.parentNode.removeChild(li);
@@ -4982,7 +4910,7 @@
     }
 
     this.reset_qsearch();
-    this.select_folder('S'+id);
+    this.select_folder('S'+id, '', true);
 
     // reset vars
     this.env.current_page = 1;
diff --git a/program/js/treelist.js b/program/js/treelist.js
new file mode 100644
index 0000000..47ac0c1
--- /dev/null
+++ b/program/js/treelist.js
@@ -0,0 +1,425 @@
+/*
+ +-----------------------------------------------------------------------+
+ | Roundcube Treelist widget                                             |
+ |                                                                       |
+ | This file is part of the Roundcube Webmail client                     |
+ | Copyright (C) 2013, The Roundcube Dev Team                            |
+ |                                                                       |
+ | Licensed under the GNU General Public License version 3 or            |
+ | any later version with exceptions for skins & plugins.                |
+ | See the README file for a full license statement.                     |
+ |                                                                       |
+ +-----------------------------------------------------------------------+
+ | Authors: Thomas Bruederli <roundcube@gmail.com>                       |
+ +-----------------------------------------------------------------------+
+ | Requires: common.js                                                   |
+ +-----------------------------------------------------------------------+
+*/
+
+
+/**
+ * Roundcube Treelist widget class
+ * @contructor
+ */
+function rcube_treelist_widget(node, p)
+{
+	// apply some defaults to p
+	p = $.extend({
+		id_prefix: '',
+		autoexpand: 1000,
+		selectable: false,
+		check_droptarget: function(node){ return !node.virtual }
+	}, p || {});
+
+	var container = $(node);
+	var data = p.data || [];
+	var indexbyid = {};
+	var selection = null;
+	var drag_active = false;
+	var box_coords = {};
+	var item_coords = [];
+	var autoexpand_timer;
+	var autoexpand_item;
+	var body_scroll_top = 0;
+	var list_scroll_top = 0;
+	var me = this;
+
+
+	/////// export public members and methods
+
+	this.container = container;
+	this.expand = expand;
+	this.collapse = collapse;
+	this.select = select;
+	this.render = render;
+	this.drag_start = drag_start;
+	this.drag_end = drag_end;
+	this.intersects = intersects;
+
+
+	/////// startup code (constructor)
+
+	// abort if node not found
+	if (!container.length)
+		return;
+
+	if (p.data) {
+		index_data({ children:data });
+	}
+	// load data from DOM
+	else {
+		data = walk_list(container);
+		// console.log(data);
+	}
+
+	// register click handlers on list
+	container.on('click', 'div.treetoggle', function(e){
+		toggle(dom2id($(this).parent()));
+	});
+
+	container.on('click', 'li', function(e){
+		var node = p.selectable ? indexbyid[dom2id($(this))] : null;
+		if (node && !node.virtual) {
+			select(node.id);
+			e.stopPropagation();
+		}
+	});
+
+
+	/////// private methods
+
+	/**
+	 * Collaps a the node with the given ID
+	 */
+	function collapse(id, recursive, set)
+	{
+		var node;
+		if (node = indexbyid[id]) {
+			node.collapsed = typeof set == 'undefined' || set;
+			update_dom(node);
+
+			// Work around a bug in IE6 and IE7, see #1485309
+			if (window.bw && (bw.ie6 || bw.ie7) && node.collapsed) {
+				id2dom(node.id).next().children('ul:visible').hide().show();
+			}
+
+			if (recursive && node.children) {
+				for (var i=0; i < node.children.length; i++) {
+					collapse(node.children[i].id, recursive, set);
+				}
+			}
+
+			me.triggerEvent(node.collapsed ? 'collapse' : 'expand', node);
+		}
+	}
+
+	/**
+	 * Expand a the node with the given ID
+	 */
+	function expand(id, recursive)
+	{
+		collapse(id, recursive, false);
+	}
+
+	/**
+	 * Toggle collapsed state of a list node
+	 */
+	function toggle(id, recursive)
+	{
+		var node;
+		if (node = indexbyid[id]) {
+			collapse(id, recursive, !node.collapsed);
+		}
+	}
+
+	/**
+	 * Select a tree node by it's ID
+	 */
+	function select(id)
+	{
+		if (selection) {
+			id2dom(selection).removeClass('selected');
+			selection = null;
+		}
+
+		var li = id2dom(id);
+		if (li.length) {
+			li.addClass('selected');
+			selection = id;
+			// TODO: expand all parent nodes if collapsed
+			scroll_to_node(li);
+		}
+
+		me.triggerEvent('select', indexbyid[id]);
+	}
+
+	/**
+	 * Getter for the currently selected node ID
+	 */
+	function get_selection()
+	{
+		return selection;
+	}
+
+	/**
+	 * Return the DOM element of the list item with the given ID
+	 */
+	function get_item(id)
+	{
+		return id2dom(id).get(0);
+	}
+
+	/**
+	 * Apply the 'collapsed' status of the data node to the corresponding DOM element(s)
+	 */
+	function update_dom(node)
+	{
+		var li = id2dom(node.id);
+		li.children('ul').first()[(node.collapsed ? 'hide' : 'show')]();
+		li.children('div.treetoggle').removeClass('collapsed expanded').addClass(node.collapsed ? 'collapsed' : 'expanded');
+		me.triggerEvent('toggle', node);
+	}
+
+	/**
+	 * Render the tree list from the internal data structure
+	 */
+	function render()
+	{
+		if (me.triggerEvent('renderBefore', data) === false)
+			return;
+
+		// remove all child nodes
+		container.html('');
+
+		// render child nodes
+		for (var i=0; i < data.length; i++) {
+			render_node(data[i], container);
+		}
+
+		me.triggerEvent('renderAfter', container);
+	}
+
+	/**
+	 * Render a specific node into the DOM list
+	 */
+	function render_node(node, parent)
+	{
+		var li = $('<li>' + node.html + '</li>')
+			.attr('id', p.id_prefix + node.id)
+			.addClass((node.classes || []).join(' '))
+			.appendTo(parent);
+
+		if (node.virtual)
+			li.addClass('virtual');
+		if (node.id == selection)
+			li.addClass('selected');
+
+		// add child list and toggle icon
+		if (node.children && node.children.length) {
+			$('<div class="treetoggle '+(node.collapsed ? 'collapsed' : 'expanded') + '">&nbsp;</div>').appendTo(li);
+			var ul = $('<ul>').appendTo(li);
+			if (node.collapsed)
+				ul.hide();
+
+			for (var i=0; i < node.children.length; i++) {
+				render_node(node.children[i], ul);
+			}
+		}
+	}
+
+	/**
+	 * Recursively walk the DOM tree and build an internal data structure
+	 * representing the skeleton of this tree list.
+	 */
+	function walk_list(ul)
+	{
+		var result = [];
+		ul.children('li').each(function(i,e){
+			var li = $(e);
+			var node = {
+				id: dom2id(li),
+				classes: li.attr('class').split(' '),
+				virtual: li.hasClass('virtual'),
+				html: li.children().first().get(0).outerHTML,
+				children: walk_list(li.children('ul'))
+			}
+
+			if (node.children.length) {
+				node.collapsed = li.children('ul').css('display') == 'none';
+			}
+			if (li.hasClass('selected')) {
+				selection = node.id;
+			}
+
+			result.push(node);
+			indexbyid[node.id] = node;
+		})
+
+		return result;
+	}
+
+	/**
+	 * Recursively walk the data tree and index nodes by their ID
+	 */
+	function index_data(node)
+	{
+		if (node.id) {
+			indexbyid[node.id] = node;
+		}
+		for (var c=0; node.children && c < node.children.length; c++) {
+			index_data(node.children[c]);
+		}
+	}
+
+	/**
+	 * Get the (stripped) node ID from the given DOM element
+	 */
+	function dom2id(li)
+	{
+		var domid = li.attr('id').replace(new RegExp('^' + (p.id_prefix) || '%'), '');
+		return p.id_decode ? p.id_decode(domid) : domid;
+	}
+
+	/**
+	 * Get the <li> element for the given node ID
+	 */
+	function id2dom(id)
+	{
+		var domid = p.id_encode ? p.id_encode(id) : id;
+		return $('#' + p.id_prefix + domid);
+	}
+
+	/**
+	 * Scroll the parent container to make the given list item visible
+	 */
+	function scroll_to_node(li)
+	{
+		var scroller = container.parent();
+		scroller.scrollTop(li.offset().top - scroller.offset().top + scroller.scrollTop());
+	}
+
+	///// drag & drop support
+
+	/**
+	 * When dragging starts, compute absolute bounding boxes of the list and it's items
+	 * for faster comparisons while mouse is moving
+	 */
+	function drag_start()
+	{
+		var li, item, height,
+			pos = container.offset();
+
+		body_scroll_top = bw.ie ? 0 : window.pageYOffset;
+		list_scroll_top = container.parent().scrollTop();
+
+		drag_active = true;
+		box_coords = {
+			x1: pos.left,
+			y1: pos.top,
+			x2: pos.left + container.width(),
+			y2: pos.top + container.height()
+		};
+
+		item_coords = [];
+		for (var id in indexbyid) {
+			li = id2dom(id);
+			item = li.children().first().get(0);
+			if (height = item.offsetHeight) {
+				pos = $(item).offset();
+				item_coords[id] = {
+					x1: pos.left,
+					y1: pos.top,
+					x2: pos.left + item.offsetWidth,
+					y2: pos.top + height,
+					on: id == autoexpand_item
+				};
+			}
+		}
+	}
+
+	/**
+	 * Signal that dragging has stopped
+	 */
+	function drag_end()
+	{
+		drag_active = false;
+
+		if (autoexpand_timer) {
+			clearTimeout(autoexpand_timer);
+			autoexpand_timer = null;
+			autoexpand_item = null;
+		}
+
+		$('li.droptarget', container).removeClass('droptarget');
+	}
+
+	/**
+	 * Determine if the given mouse coords intersect the list and one if its items
+	 */
+	function intersects(mouse, highlight)
+	{
+		// offsets to compensate for scrolling while dragging a message
+		var boffset = bw.ie ? -document.documentElement.scrollTop : body_scroll_top,
+			moffset = list_scroll_top - container.parent().scrollTop(),
+			result = null;
+
+		mouse.top = mouse.y + -moffset - boffset;
+
+		// no intersection with list bounding box
+		if (mouse.x < box_coords.x1 || mouse.x >= box_coords.x2 || mouse.top < box_coords.y1 || mouse.top >= box_coords.y2) {
+		  // TODO: optimize performance for this operation
+		  $('li.droptarget', container).removeClass('droptarget');
+			return result;
+		}
+
+		// check intersection with visible list items
+		var pos, node;
+		for (var id in item_coords) {
+			pos = item_coords[id];
+			if (mouse.x >= pos.x1 && mouse.x < pos.x2 && mouse.top >= pos.y1 && mouse.top < pos.y2) {
+				node = indexbyid[id];
+
+				// if the folder is collapsed, expand it after the configured time
+				if (node.children && node.children.length && node.collapsed && p.autoexpand && autoexpand_item != id) {
+					if (autoexpand_timer)
+						clearTimeout(autoexpand_timer);
+
+					autoexpand_item = id;
+					autoexpand_timer = setTimeout(function() {
+						expand(autoexpand_item);
+						drag_start();  // re-calculate item coords
+						autoexpand_item = null;
+					}, p.autoexpand);
+				}
+				else if (autoexpand_timer && autoexpand_item != id) {
+					clearTimeout(autoexpand_timer);
+					autoexpand_item = null;
+					autoexpand_timer = null;
+				}
+
+				// check if this item is accepted as drop target
+				if (p.check_droptarget(node)) {
+					if (highlight) {
+						id2dom(id).addClass('droptarget');
+						pos.on = true;
+					}
+					result = id;
+				}
+				else {
+					result = null;
+				}
+			}
+			else if (pos.on) {
+				id2dom(id).removeClass('droptarget');
+				pos.on = false;
+			}
+		}
+
+		return result;
+	}
+}
+
+// use event processing functions from Roundcube's rcube_event_engine
+rcube_treelist_widget.prototype.addEventListener = rcube_event_engine.prototype.addEventListener;
+rcube_treelist_widget.prototype.removeEventListener = rcube_event_engine.prototype.removeEventListener;
+rcube_treelist_widget.prototype.triggerEvent = rcube_event_engine.prototype.triggerEvent;
diff --git a/program/steps/addressbook/func.inc b/program/steps/addressbook/func.inc
index 7fb862d..80631cd 100644
--- a/program/steps/addressbook/func.inc
+++ b/program/steps/addressbook/func.inc
@@ -187,7 +187,7 @@
     $jsdata = array();
 
     $line_templ = html::tag('li', array(
-        'id' => 'rcmli%s', 'class' => '%s'),
+        'id' => 'rcmli%s', 'class' => '%s', 'noclose' => true),
         html::a(array('href' => '%s',
             'rel' => '%s',
             'onclick' => "return ".JS_OBJECT_NAME.".command('list','%s',this)"), '%s'));
@@ -213,7 +213,7 @@
 
         $name = !empty($source['name']) ? $source['name'] : $id;
         $out .= sprintf($line_templ,
-            html_identifier($id),
+            rcube_utils::html_identifier($id, true),
             $class_name,
             Q(rcmail_url(null, array('_source' => $id))),
             $source['id'],
@@ -224,10 +224,11 @@
             $groupdata = rcmail_contact_groups($groupdata);
         $jsdata = $groupdata['jsdata'];
         $out = $groupdata['out'];
+        $out .= '</li>';
     }
 
     $line_templ = html::tag('li', array(
-        'id' => 'rcmliS%s', 'class' => '%s'),
+        'id' => 'rcmli%s', 'class' => '%s'),
         html::a(array('href' => '#', 'rel' => 'S%s',
             'onclick' => "return ".JS_OBJECT_NAME.".command('listsearch', '%s', this)"), '%s'));
 
@@ -245,14 +246,17 @@
             $class_name .= ' ' . $source['class_name'];
 
         $out .= sprintf($line_templ,
-            html_identifier($id),
+            rcube_utils::html_identifier('S'.$id, true),
             $class_name,
             $id,
             $js_id, (!empty($source['name']) ? Q($source['name']) : Q($id)));
     }
 
     $OUTPUT->set_env('contactgroups', $jsdata);
+    $OUTPUT->set_env('collapsed_abooks', (string)$RCMAIL->config->get('collapsed_abooks',''));
     $OUTPUT->add_gui_object('folderlist', $attrib['id']);
+    $OUTPUT->include_script('treelist.js');
+
     // add some labels to client
     $OUTPUT->add_label('deletegroupconfirm', 'groupdeleting', 'addingmember', 'removingmember');
 
@@ -265,18 +269,24 @@
     global $RCMAIL;
 
     $groups = $RCMAIL->get_address_book($args['source'])->list_groups();
+    $js_id = $RCMAIL->JQ($args['source']);
 
     if (!empty($groups)) {
         $line_templ = html::tag('li', array(
-            'id' => 'rcmliG%s', 'class' => 'contactgroup'),
+            'id' => 'rcmli%s', 'class' => 'contactgroup'),
             html::a(array('href' => '#',
                 'rel' => '%s:%s',
                 'onclick' => "return ".JS_OBJECT_NAME.".command('listgroup',{'source':'%s','id':'%s'},this)"), '%s'));
 
+        // append collapse/expand toggle and open a new <ul>
+        $is_collapsed = strpos($RCMAIL->config->get('collapsed_abooks',''), '&'.rawurlencode($args['source']).'&') !== false;
+        $args['out'] .= html::div('treetoggle ' . ($is_collapsed ? 'collapsed' : 'expanded'), '&nbsp;');
+
         $jsdata = array();
+        $groups_html = '';
         foreach ($groups as $group) {
-            $args['out'] .= sprintf($line_templ,
-                html_identifier($args['source'] . $group['ID']),
+            $groups_html .= sprintf($line_templ,
+                rcube_utils::html_identifier('G' . $args['source'] . $group['ID'], true),
                 $args['source'], $group['ID'],
                 $args['source'], $group['ID'], Q($group['name'])
             );
@@ -286,6 +296,10 @@
         }
     }
 
+    $args['out'] .= html::tag('ul',
+      array('class' => 'groups', 'style' => ($is_collapsed ? "display:none;" : null)),
+      $groups_html);
+
     return $args;
 }
 
diff --git a/skins/classic/addressbook.css b/skins/classic/addressbook.css
index 7831453..5afa459 100644
--- a/skins/classic/addressbook.css
+++ b/skins/classic/addressbook.css
@@ -118,7 +118,7 @@
 
 #directorylistbox input
 {
-  margin: 0px;
+  margin: 0 0 0 20px;
   font-size: 11px;
   width: 90%;
 }
@@ -144,7 +144,8 @@
   width: 280px;
 }
 
-#directorylist
+#directorylist,
+#directorylist li ul
 {
   list-style: none;
   margin: 0;
@@ -152,11 +153,15 @@
   background-color: #FFFFFF;
 }
 
+#directorylist li ul
+{
+  border-top: 1px solid #EBEBEB;
+}
+
 #directorylist li
 {
   display: block;
   font-size: 11px;
-  background: url(images/icons/folders.png) 5px -108px no-repeat;
   border-bottom: 1px solid #EBEBEB;
   white-space: nowrap;
 }
@@ -168,31 +173,37 @@
   padding-left: 25px;
   padding-top: 2px;
   padding-bottom: 2px;
+  height: 16px;
   text-decoration: none;
   white-space: nowrap;
+  background: url(images/icons/folders.png) 5px -108px no-repeat;
 }
 
-#directorylist li.contactgroup
+#directorylist li ul li a
 {
-  padding-left: 15px;
-  background-position: 20px -143px;
+  padding-left: 45px;
 }
 
-#directorylist li.contactsearch
+#directorylist li ul li:last-child
+{
+  border-bottom: 0;
+}
+
+#directorylist li.contactgroup a
+{
+  background-position: 22px -143px;
+}
+
+#directorylist li.contactsearch a
 {
   background-position: 6px -162px;
 }
 
-#directorylist li.selected
-{
-  background-color: #929292;
-  border-bottom: 1px solid #898989;
-}
-
-#directorylist li.selected a
+#directorylist li.selected > a
 {
   color: #FFF;
   font-weight: bold;
+  background-color: #929292;
 }
 
 #directorylist li.droptarget
diff --git a/skins/classic/templates/addressbook.html b/skins/classic/templates/addressbook.html
index 404fb2c..9d959d5 100644
--- a/skins/classic/templates/addressbook.html
+++ b/skins/classic/templates/addressbook.html
@@ -63,8 +63,7 @@
 <div id="directorylistbox">
 <div id="directorylist-title" class="boxtitle"><roundcube:label name="groups" /></div>
 <div id="directorylist-content" class="boxlistcontent">
-  <roundcube:object name="directorylist" id="directorylist" />
-  <roundcube:object name="groupslist" id="contactgroupslist" />
+  <roundcube:object name="directorylist" id="directorylist" class="treelist" />
 </div>
 <div id="directorylist-footer" class="boxfooter">
   <roundcube:button command="group-create" type="link" title="newcontactgroup" class="buttonPas addgroup" classAct="button addgroup" content=" " />
diff --git a/skins/larry/addressbook.css b/skins/larry/addressbook.css
index a1c4538..ff39514 100644
--- a/skins/larry/addressbook.css
+++ b/skins/larry/addressbook.css
@@ -83,8 +83,21 @@
 	background-position: 6px -766px;
 }
 
-#directorylist li.addressbook.selected a {
+#directorylist li.addressbook.selected > a {
 	background-position: 6px -791px;
+}
+
+#directorylist li.addressbook ul li:last-child {
+	border-bottom: 0;
+}
+
+#directorylist li.addressbook ul.groups {
+	margin: 0;
+	padding: 0;
+}
+
+#directorylist li.addressbook ul.groups li {
+	width: 100%;
 }
 
 #directorylist li.contactgroup a {
@@ -112,6 +125,12 @@
 	margin-left: 8px;
 }
 
+#directorylist li.addressbook div.collapsed,
+#directorylist li.addressbook div.expanded {
+	top: 15px;
+	left: 20px;
+}
+
 #contacts-table .contact td.name {
 	background-position: 6px -1603px;
 }
diff --git a/skins/larry/templates/addressbook.html b/skins/larry/templates/addressbook.html
index 390f8d8..401640f 100644
--- a/skins/larry/templates/addressbook.html
+++ b/skins/larry/templates/addressbook.html
@@ -30,7 +30,7 @@
 <div id="directorylistbox" class="uibox listbox">
 <h2 id="directorylist-header" class="boxtitle"><roundcube:label name="groups" /></h2>
 <div id="directorylist-content" class="scroller withfooter">
-	<roundcube:object name="directorylist" id="directorylist" class="listing" />
+	<roundcube:object name="directorylist" id="directorylist" class="treelist listing" />
 </div>
 <div id="directorylist-footer" class="boxfooter">
 	<roundcube:button command="group-create" type="link" title="newcontactgroup" class="listbutton add disabled" classAct="listbutton add" innerClass="inner" content="+" /><roundcube:button name="groupoptions" id="groupoptionslink" type="link" title="moreactions" class="listbutton groupactions" onclick="UI.show_popup('groupoptions');return false" innerClass="inner" content="&#9881;" />

--
Gitblit v1.9.1