Thomas Bruederli
2013-07-10 bff2ad530637263002b24a4f7dcf28b82b94dca9
Backported the treelist refactoring to 0.9
1 files added
19 files modified
1121 ■■■■ changed files
program/include/rcmail.php 14 ●●●● patch | view | raw | blame | history
program/js/app.js 226 ●●●●● patch | view | raw | blame | history
program/js/treelist.js 575 ●●●●● patch | view | raw | blame | history
program/steps/addressbook/func.inc 28 ●●●● patch | view | raw | blame | history
skins/classic/addressbook.css 39 ●●●●● patch | view | raw | blame | history
skins/classic/common.css 27 ●●●●● patch | view | raw | blame | history
skins/classic/mail.css 26 ●●●●● patch | view | raw | blame | history
skins/classic/templates/addressbook.html 3 ●●●● patch | view | raw | blame | history
skins/classic/templates/mail.html 2 ●●● patch | view | raw | blame | history
skins/classic/templates/message.html 2 ●●● patch | view | raw | blame | history
skins/classic/templates/messageerror.html 2 ●●● patch | view | raw | blame | history
skins/larry/addressbook.css 21 ●●●●● patch | view | raw | blame | history
skins/larry/includes/footer.html 12 ●●●●● patch | view | raw | blame | history
skins/larry/mail.css 100 ●●●●● patch | view | raw | blame | history
skins/larry/styles.css 30 ●●●●● patch | view | raw | blame | history
skins/larry/templates/addressbook.html 2 ●●● patch | view | raw | blame | history
skins/larry/templates/mail.html 2 ●●● patch | view | raw | blame | history
skins/larry/templates/message.html 2 ●●● patch | view | raw | blame | history
skins/larry/templates/messageerror.html 2 ●●● patch | view | raw | blame | history
skins/larry/ui.js 6 ●●●● patch | view | raw | blame | history
program/include/rcmail.php
@@ -1414,6 +1414,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']);
@@ -1592,14 +1593,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)
                ), ' ') : ''));
                html::a($link_attrib, $html_name));
            $jslist[$folder_id] = array(
            if (!empty($folder['folders'])) {
                $out .= html::div('treetoggle ' . ($is_collapsed ? 'collapsed' : 'expanded'), ' ');
            }
            $jslist[$folder['id']] = array(
                'id'      => $folder['id'],
                'name'    => $foldername,
                'virtual' => $folder['virtual']
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)) {
@@ -1257,11 +1270,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)
@@ -1314,29 +1328,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)
@@ -1344,87 +1338,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)
@@ -1434,40 +1369,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)
@@ -1492,9 +1420,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);
@@ -4188,7 +4116,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;
@@ -4495,7 +4423,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);
      }
@@ -4516,7 +4444,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);
@@ -4538,8 +4466,22 @@
      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);
      this.name_input_li.insertAfter(li);
      var ul, li;
      // find list (UL) element
      if (type == 'contactsearch')
        ul = this.gui_objects.folderlist;
      else
        ul = $('ul.groups', this.get_folder_li(this.env.source,'',true));
      // append to the list
      li = $('li:last', ul);
      if (li.length)
        this.name_input_li.insertAfter(li);
      else {
        this.name_input_li.appendTo(ul);
        ul.show(); // make sure the list is visible
      }
    }
    this.name_input.select().focus();
@@ -4596,11 +4538,13 @@
  this.reset_add_input = function()
  {
    if (this.name_input) {
      var li = this.name_input.parent();
      if (this.env.group_renaming) {
        var li = this.name_input.parent();
        li.children().last().show();
        this.env.group_renaming = false;
      }
      else if ($('li', li.parent()).length == 1)
        li.parent().hide();
      this.name_input.remove();
@@ -4639,7 +4583,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
@@ -4678,8 +4622,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) {
@@ -4928,12 +4872,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;
@@ -4987,7 +4931,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);
@@ -5009,7 +4953,7 @@
    }
    this.reset_qsearch();
    this.select_folder('S'+id);
    this.select_folder('S'+id, '', true);
    // reset vars
    this.env.current_page = 1;
program/js/treelist.js
New file
@@ -0,0 +1,575 @@
/*
 +-----------------------------------------------------------------------+
 | 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),
    data = p.data || [],
    indexbyid = {},
    selection = null,
    drag_active = false,
    box_coords = {},
    item_coords = [],
    autoexpand_timer,
    autoexpand_item,
    body_scroll_top = 0,
    list_scroll_top = 0,
    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;
  this.update = update_node;
  this.insert = insert;
  this.remove = remove;
  this.get_item = get_item;
  this.get_selection = get_selection;
  /////// startup code (constructor)
  // abort if node not found
  if (!container.length)
    return;
  if (p.data)
    index_data({ children:data });
  // load data from DOM
  else
    update_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);
  }
  /**
   * Insert the given node
   */
  function insert(node, parent_id, sort)
  {
    var li, parent_li,
      parent_node = parent_id ? indexbyid[parent_id] : null;
    // insert as child of an existing node
    if (parent_node) {
      if (!parent_node.children)
        parent_node.children = [];
      parent_node.children.push(node);
      parent_li = id2dom(parent_id);
      // re-render the entire subtree
      if (parent_node.children.length == 1) {
        render_node(parent_node, parent_li.parent(), parent_li);
        li = id2dom(node.id);
      }
      else {
        // append new node to parent's child list
        li = render_node(node, parent_li.children('ul').first());
      }
    }
    // insert at top level
    else {
      data.push(node);
      li = render_node(node, container);
    }
    indexbyid[node.id] = node;
    if (sort) {
      resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
    }
  }
  /**
   * Update properties of an existing node
   */
  function update_node(id, updates, sort)
  {
    var li, node = indexbyid[id];
    if (node) {
      li = id2dom(id);
      if (updates.id || updates.html || updates.children || updates.classes) {
        $.extend(node, updates);
        render_node(node, li.parent(), li);
      }
      if (node.id != id) {
        delete indexbyid[id];
        indexbyid[node.id] = node;
      }
      if (sort) {
        resort_node(li, typeof sort == 'string' ? '[class~="' + sort + '"]' : '');
      }
    }
  }
  /**
   * Helper method to sort the list of the given item
   */
  function resort_node(li, filter)
  {
    var first, sibling,
      myid = li.get(0).id,
      sortname = li.children().first().text().toUpperCase();
    li.parent().children('li' + filter).each(function(i, elem) {
      if (i == 0)
        first = elem;
      if (elem.id == myid) {
        // skip
      }
      else if (elem.id != myid && sortname >= $(elem).children().first().text().toUpperCase()) {
        sibling = elem;
      }
      else {
        return false;
      }
    });
    if (sibling) {
      li.insertAfter(sibling);
    }
    else if (first.id != myid) {
      li.insertBefore(first);
    }
    // reload data from dom
    update_data();
  }
  /**
   * Remove the item with the given ID
   */
  function remove(id)
  {
    var node, li;
    if (node = indexbyid[id]) {
      li = id2dom(id);
      li.remove();
      node.deleted = true;
      delete indexbyid[id];
      return true;
    }
    return false;
  }
  /**
   * (Re-)read tree data from DOM
   */
  function update_data()
  {
    data = walk_list(container);
  }
  /**
   * 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, replace)
  {
    if (node.deleted)
      return;
    var li = $('<li>')
      .attr('id', p.id_prefix + (p.id_encode ? p.id_encode(node.id) : node.id))
      .addClass((node.classes || []).join(' '));
    if (replace)
      replace.replaceWith(li);
    else
      li.appendTo(parent);
    if (typeof node.html == 'string')
      li.html(node.html);
    else if (typeof node.html == 'object')
      li.append(node.html);
    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).attr('class', node.childlistclass);
      if (node.collapsed)
        ul.hide();
      for (var i=0; i < node.children.length; i++) {
        render_node(node.children[i], ul);
      }
    }
    return li;
  }
  /**
   * 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), sublist = li.children('ul');
      var node = {
        id: dom2id(li),
        classes: li.attr('class').split(' '),
        virtual: li.hasClass('virtual'),
        html: li.children().first().get(0).outerHTML,
        children: walk_list(sublist)
      }
      if (sublist.length) {
        node.childlistclass = sublist.attr('class');
      }
      if (node.children.length) {
        node.collapsed = sublist.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(),
      current_offset = scroller.scrollTop(),
      rel_offset = li.offset().top - scroller.offset().top;
    if (rel_offset < 0 || rel_offset + li.height() > scroller.height())
      scroller.scrollTop(rel_offset + current_offset);
  }
  ///// 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;
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']);
    $groups_html = '';
    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();
        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 || empty($groups) ? "display:none;" : null)),
      $groups_html);
    return $args;
}
skins/classic/addressbook.css
@@ -110,7 +110,7 @@
#directorylistbox input
{
  margin: 0px;
  margin: 0 0 0 20px;
  font-size: 11px;
  width: 90%;
}
@@ -136,7 +136,8 @@
  width: 280px;
}
#directorylist
#directorylist,
#directorylist li ul
{
  list-style: none;
  margin: 0;
@@ -144,11 +145,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;
}
@@ -160,31 +165,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
skins/classic/common.css
@@ -622,6 +622,32 @@
  background-color: #929292;
}
ul.treelist li
{
  position: relative;
}
ul.treelist li div.treetoggle
{
  position: absolute;
  left: 8px !important;
  left: -16px;
  top: 1px;
  width: 14px;
  height: 16px;
  cursor: pointer;
}
ul.treelist li div.collapsed
{
  background: url(images/icons/collapsed.png) bottom right no-repeat;
}
ul.treelist li div.expanded
{
  background: url(images/icons/expanded.png) bottom right no-repeat;
}
/***** mac-style quicksearch field *****/
@@ -666,6 +692,7 @@
  font-size: 11px;
  padding: 0px;
  border: none;
  outline: none;
}
/***** roundcube webmail pre-defined classes *****/
skins/classic/mail.css
@@ -393,32 +393,6 @@
  border-bottom: none;
}
#mailboxlist li div
{
  position: absolute;
  left: 8px !important;
  left: -16px;
  top: 1px;
  width: 14px;
  height: 16px;
}
#mailboxlist li div.collapsed,
#mailboxlist li div.expanded
{
  cursor: pointer;
}
#mailboxlist li div.collapsed
{
  background: url(images/icons/collapsed.png) bottom right no-repeat;
}
#mailboxlist li div.expanded
{
  background: url(images/icons/expanded.png) bottom right no-repeat;
}
#mailboxlist li.inbox
{
  background-position: 5px -18px;
skins/classic/templates/addressbook.html
@@ -54,8 +54,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=" " />
skins/classic/templates/mail.html
@@ -28,7 +28,7 @@
<div id="mailboxlist-container">
<div id="mailboxlist-title" class="boxtitle"><roundcube:label name="mailboxlist" /></div>
<div id="mailboxlist-content"  class="boxlistcontent">
<roundcube:object name="mailboxlist" id="mailboxlist" folder_filter="mail" />
<roundcube:object name="mailboxlist" id="mailboxlist" class="treelist" folder_filter="mail" />
</div>
<div id="mailboxlist-footer" class="boxfooter">
  <roundcube:button name="mailboxmenulink" id="mailboxmenulink" type="link" title="folderactions" class="button groupactions" onclick="rcmail_ui.show_popup('mailboxmenu');return false" content=" " />
skins/classic/templates/message.html
@@ -30,7 +30,7 @@
<div id="mailboxlist-container">
<div id="mailboxlist-title" class="boxtitle"><roundcube:label name="mailboxlist" /></div>
<div class="boxlistcontent">
    <roundcube:object name="mailboxlist" id="mailboxlist" maxlength="25" />
    <roundcube:object name="mailboxlist" id="mailboxlist" class="treelist" maxlength="25" />
</div>
<div class="boxfooter"></div>
</div>
skins/classic/templates/messageerror.html
@@ -42,7 +42,7 @@
<div id="mailboxlist-container">
<div class="boxtitle"><roundcube:label name="mailboxlist" /></div>
<div class="boxlistcontent">
    <roundcube:object name="mailboxlist" id="mailboxlist" folder_filter="mail" />
    <roundcube:object name="mailboxlist" id="mailboxlist" class="treelist" folder_filter="mail" />
</div>
<div class="boxfooter"></div>
</div>
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;
}
skins/larry/includes/footer.html
@@ -8,4 +8,16 @@
});
</script>
<!--[if lte IE 8]>
<script type="text/javascript">
// fix missing :last-child selectors
$(document).ready(function(){
    $('ul.treelist ul').each(function(i,ul){
        $('li:last-child', ul).css('border-bottom', 0);
    });
});
</script>
<![endif]-->
skins/larry/mail.css
@@ -141,7 +141,7 @@
    background-position: 6px 2px;
}
#mailboxlist li:first-child {
#mailboxlist > li:first-child {
    border-radius: 4px 4px 0 0;
    border-top: 0;
}
@@ -156,7 +156,7 @@
    background-position: 6px 3px;
}
#mailboxlist li.mailbox.unread a {
#mailboxlist li.mailbox.unread > a {
    padding-right: 36px;
}
@@ -230,6 +230,17 @@
    color: #017cb4;
}
#mailboxlist li.mailbox div.treetoggle {
    top: 13px;
    left: 19px;
}
#mailboxlist li.mailbox ul li:last-child {
    border-bottom: 0;
}
/* nested mailboxes */
#mailboxlist li.mailbox ul {
    list-style: none;
    margin: 0;
@@ -237,52 +248,59 @@
    border-top: 1px solid #bbd3da;
}
#mailboxlist li.mailbox ul li {
    padding-left: 26px;
}
#mailboxlist li.mailbox ul li a {
    background-position: 6px -93px;
    padding-left: 52px;  /* 36 + 1 x 16 */
    background-position: 22px -93px;  /* 6 + 1 x 16 */
}
#mailboxlist li.mailbox ul li.selected > a {
    background-position: 6px -117px;
    background-position: 22px -117px;
}
#mailboxlist li.mailbox ul li:last-child {
    border-bottom: 0;
}
#mailboxlist li.mailbox div.collapsed,
#mailboxlist li.mailbox div.expanded {
    position: absolute;
    top: 13px;
    left: 19px;
    width: 13px;
    height: 13px;
    background: url(images/listicons.png) -3px -144px no-repeat;
    cursor: pointer;
}
#mailboxlist li.mailbox div.expanded {
    background-position: -3px -168px;
}
#mailboxlist li.mailbox.selected > div.collapsed {
    background-position: -23px -144px;
}
#mailboxlist li.mailbox.selected > div.expanded {
    background-position: -23px -168px;
}
#mailboxlist li.mailbox ul li div.collapsed,
#mailboxlist li.mailbox ul li div.expanded {
    left: 43px;
#mailboxlist li.mailbox ul li div.treetoggle {
    left: 33px;
    top: 14px;
}
#mailboxlist li.mailbox ul ul li.mailbox a {
    padding-left: 68px;  /* 2x */
    background-position: 38px -93px;
}
#mailboxlist li.mailbox ul ul li.selected > a {
    background-position: 38px -117px;
}
#mailboxlist li.mailbox ul ul li div.treetoggle {
    left: 48px;
}
#mailboxlist li.mailbox ul ul ul li.mailbox a {
    padding-left: 84px;  /* 3x */
    background-position: 54px -93px;
}
#mailboxlist li.mailbox ul ul ul li.selected > a {
    background-position: 54px -117px;
}
#mailboxlist li.mailbox ul ul ul li div.treetoggle {
    left: 64px;
}
#mailboxlist li.mailbox ul ul ul ul li.mailbox a {
    padding-left: 100px;  /* 4x */
    background-position: 70px -93px;
}
#mailboxlist li.mailbox ul ul ul ul li.selected > a {
    background-position: 70px -117px;
}
#mailboxlist li.mailbox ul ul ul ul li div.treetoggle {
    left: 80px;
}
/* indent folders on levels > 4 */
#mailboxlist li.mailbox ul ul ul ul ul li {
    padding-left: 16px;
}
#mailboxlist li.mailbox ul ul ul ul ul li div.treetoggle {
    left: 96px;
}
#mailboxlist li.mailbox .unreadcount {
    position: absolute;
    top: 3px;
skins/larry/styles.css
@@ -986,6 +986,10 @@
    background-color: #d9ecf4;
}
ul.listing li ul {
    border-top: 1px solid #bbd3da;
}
ul.listing li.droptarget,
table.listing tr.droptarget td {
    background-color: #e8e798;
@@ -1002,6 +1006,32 @@
    vertical-align: top;
}
ul.treelist li {
    position: relative;
}
ul.treelist li div.treetoggle {
    position: absolute;
    top: 13px;
    left: 19px;
    width: 13px;
    height: 13px;
    background: url(images/listicons.png) -3px -144px no-repeat;
    cursor: pointer;
}
ul.treelist li div.treetoggle.expanded {
    background-position: -3px -168px;
}
ul.treelist li.selected > div.collapsed {
    background-position: -23px -144px;
}
ul.treelist li.selected > div.expanded {
    background-position: -23px -168px;
}
.listbox .boxfooter {
    position: absolute;
    bottom: 0;
skins/larry/templates/addressbook.html
@@ -26,7 +26,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;" />
skins/larry/templates/mail.html
@@ -30,7 +30,7 @@
<div id="folderlist-header"></div>
<div id="mailboxcontainer" class="uibox listbox">
<div id="folderlist-content" class="scroller withfooter">
<roundcube:object name="mailboxlist" id="mailboxlist" class="listing" folder_filter="mail" unreadwrap="%s" />
<roundcube:object name="mailboxlist" id="mailboxlist" class="treelist listing" folder_filter="mail" unreadwrap="%s" />
</div>
<div id="folderlist-footer" class="boxfooter">
    <roundcube:button name="mailboxmenulink" id="mailboxmenulink" type="link" title="folderactions" class="listbutton groupactions" onclick="UI.show_popup('mailboxmenu');return false" innerClass="inner" content="&#9881;" />
skins/larry/templates/message.html
@@ -28,7 +28,7 @@
<!-- folders list -->
<div id="mailboxcontainer" class="uibox listbox">
<div class="scroller">
<roundcube:object name="mailboxlist" id="mailboxlist" class="listing" folder_filter="mail" unreadwrap="%s" />
<roundcube:object name="mailboxlist" id="mailboxlist" class="treelist listing" folder_filter="mail" unreadwrap="%s" />
</div>
</div>
skins/larry/templates/messageerror.html
@@ -28,7 +28,7 @@
<!-- folders list -->
<div id="mailboxcontainer" class="uibox listbox">
<div class="scroller">
    <roundcube:object name="mailboxlist" id="mailboxlist" class="listing" folder_filter="mail" unreadwrap="%s" />
    <roundcube:object name="mailboxlist" id="mailboxlist" class="treelist listing" folder_filter="mail" unreadwrap="%s" />
</div>
</div>
skins/larry/ui.js
@@ -1,7 +1,7 @@
/**
 * Roundcube functions for default skin interface
 *
 * Copyright (c) 2013, The Roundcube Dev Team
 * Copyright (c) 2011, The Roundcube Dev Team
 *
 * The contents are subject to the Creative Commons Attribution-ShareAlike
 * License. It is allowed to copy, distribute, transmit and to adapt the work
@@ -943,11 +943,11 @@
  this.delay = 500;
  this.top
    .mouseenter(function() { if (rcmail.drag_active) ref.ts = window.setTimeout(function() { ref.scroll('down'); }, ref.delay); })
    .mouseenter(function() { ref.ts = window.setTimeout(function() { ref.scroll('down'); }, ref.delay); })
    .mouseout(function() { if (ref.ts) window.clearTimeout(ref.ts); });
  this.bottom
    .mouseenter(function() { if (rcmail.drag_active) ref.ts = window.setTimeout(function() { ref.scroll('up'); }, ref.delay); })
    .mouseenter(function() { ref.ts = window.setTimeout(function() { ref.scroll('up'); }, ref.delay); })
    .mouseout(function() { if (ref.ts) window.clearTimeout(ref.ts); });
  this.scroll = function(dir)