Bug 587503 - Improve reordering tabs in groups [r=ian, a=blocking2.0-fail+]
authorMichael Yoshitaka Erlewine <mitcho@mitcho.com>
Wed, 12 Jan 2011 16:48:42 -0500
changeset 60409 9217634f7b9e19483ad33b264c8d0cfe64455fed
parent 60408 aa76da2d163e2aea86f3e66f95281069fa71be92
child 60410 3a28b3c0848a94b11c23f99dc508dad0064b58d6
push id17974
push userian@iangilman.com
push dateWed, 12 Jan 2011 21:55:17 +0000
treeherdermozilla-central@9217634f7b9e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersian, blocking2.0-fail
bugs587503
milestone2.0b10pre
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 587503 - Improve reordering tabs in groups [r=ian, a=blocking2.0-fail+]
browser/base/content/tabview/drag.js
browser/base/content/tabview/groupitems.js
browser/base/content/tabview/items.js
browser/base/content/tabview/tabitems.js
browser/base/content/tabview/ui.js
browser/base/content/test/tabview/Makefile.in
browser/base/content/test/tabview/browser_tabview_bug587503.js
--- a/browser/base/content/tabview/drag.js
+++ b/browser/base/content/tabview/drag.js
@@ -291,17 +291,20 @@ Drag.prototype = {
     if (this.parent && !this.parent.locked.close && this.parent != this.item.parent &&
        this.parent.isEmpty()) {
       this.parent.close();
     }
 
     if (this.parent && this.parent.expanded)
       this.parent.arrange();
 
-    if (this.item && !this.item.parent) {
+    if (this.item.parent)
+      this.item.parent.arrange();
+
+    if (!this.item.parent) {
       this.item.setZ(drag.zIndex);
       drag.zIndex++;
 
       this.item.pushAway(immediately);
     }
 
     Trenches.disactivate();
   }
--- a/browser/base/content/tabview/groupitems.js
+++ b/browser/base/content/tabview/groupitems.js
@@ -277,17 +277,17 @@ function GroupItem(listOfEls, options) {
   // ___ Superclass initialization
   this._init($container[0]);
 
   if (this.$debug)
     this.$debug.css({zIndex: -1000});
 
   // ___ Children
   Array.prototype.forEach.call(listOfEls, function(el) {
-    self.add(el, null, options);
+    self.add(el, options);
   });
 
   // ___ Finish Up
   this._addHandlers($container);
 
   if (!this.locked.bounds)
     this.setResizable(true, immediately);
 
@@ -806,24 +806,24 @@ GroupItem.prototype = Utils.extend(new I
 
   // ----------
   // Function: add
   // Adds an item to the groupItem.
   // Parameters:
   //
   //   a - The item to add. Can be an <Item>, a DOM element or an iQ object.
   //       The latter two must refer to the container of an <Item>.
-  //   dropPos - An object with left and top properties referring to the 
-  //             location dropped at.  Optional.
-  //   options - An optional object with settings for this call. See below.
+  //   options - An object with optional settings for this call.
+  //
+  // Options:
   //
-  // Possible options:
-  //   dontArrange - Don't rearrange the children for the new item
-  //   immediately - Don't animate
-  add: function GroupItem_add(a, dropPos, options) {
+  //   index - (int) if set, add this tab at this index
+  //   immediately - (bool) if true, no animation will be used
+  //   dontArrange - (bool) if true, will not trigger an arrange on the group
+  add: function GroupItem_add(a, options) {
     try {
       var item;
       var $el;
       if (a.isAnItem) {
         item = a;
         $el = iQ(a.container);
       } else {
         $el = iQ(a);
@@ -841,58 +841,18 @@ GroupItem.prototype = Utils.extend(new I
 
       var wasAlreadyInThisGroupItem = false;
       var oldIndex = this._children.indexOf(item);
       if (oldIndex != -1) {
         this._children.splice(oldIndex, 1);
         wasAlreadyInThisGroupItem = true;
       }
 
-      // TODO: You should be allowed to drop in the white space at the bottom
-      // and have it go to the end (right now it can match the thumbnail above
-      // it and go there)
-      // Bug 586548
-      function findInsertionPoint(dropPos) {
-        if (self.shouldStack(self._children.length + 1))
-          return 0;
-
-        var best = {dist: Infinity, item: null};
-        var index = 0;
-        var box;
-        self._children.forEach(function(child) {
-          box = child.getBounds();
-          if (box.bottom < dropPos.top || box.top > dropPos.top)
-            return;
-
-          var dist = Math.sqrt(Math.pow((box.top+box.height/2)-dropPos.top,2)
-              + Math.pow((box.left+box.width/2)-dropPos.left,2));
-
-          if (dist <= best.dist) {
-            best.item = child;
-            best.dist = dist;
-            best.index = index;
-          }
-        });
-
-        if (self._children.length) {
-          if (best.item) {
-            box = best.item.getBounds();
-            var insertLeft = dropPos.left <= box.left + box.width/2;
-            if (!insertLeft)
-              return best.index+1;
-            return best.index;
-          }
-          return self._children.length;
-        }
-
-        return 0;
-      }
-
       // Insert the tab into the right position.
-      var index = dropPos ? findInsertionPoint(dropPos) : this._children.length;
+      var index = ("index" in options) ? options.index : this._children.length;
       this._children.splice(index, 0, item);
 
       item.setZ(this.getZ() + 1);
       $el.addClass("tabInGroupItem");
 
       if (!wasAlreadyInThisGroupItem) {
         item.droppable(false);
         item.groupItemData = {};
@@ -1115,72 +1075,104 @@ GroupItem.prototype = Utils.extend(new I
     return shouldStack;
   },
 
   // ----------
   // Function: arrange
   // Lays out all of the children.
   //
   // Parameters:
-  //   options - passed to <Items.arrange> or <_stackArrange>
+  //   options - passed to <Items.arrange> or <_stackArrange>, except those below
+  //
+  // Options:
+  //   addTab - (boolean) if true, we add one to the child count
+  //   oldDropIndex - if set, we will only set any bounds if the dropIndex has
+  //                  changed
+  //   dropPos - (<Point>) a position where a tab is currently positioned, above
+  //             this group.
+  //   animate - (boolean) if true, movement of children will be animated.
+  //
+  // Returns:
+  //   dropIndex - an index value for where an item would be dropped, if 
+  //               options.dropPos is given.
   arrange: function GroupItem_arrange(options) {
+    if (!options)
+      options = {};
+
+    let childrenToArrange = [];
+    this._children.forEach(function(child) {
+      if (child.isDragging)
+        options.addTab = true;
+      else
+        childrenToArrange.push(child);
+    });
+
     if (GroupItems._arrangePaused) {
       GroupItems.pushArrange(this, options);
       return;
     }
+    var dropIndex = false;
     if (this.expanded) {
       this.topChild = null;
       var box = new Rect(this.expanded.bounds);
       box.inset(8, 8);
-      Items.arrange(this._children, box, Utils.extend({}, options, {z: 99999}));
+      let result = Items.arrange(childrenToArrange, box, Utils.extend({}, options, {z: 99999}));
+      dropIndex = result.dropIndex;
     } else {
+      var count = childrenToArrange.length;
       var bb = this.getContentBounds();
-      if (!this.shouldStack()) {
-        if (!options)
-          options = {};
-
-        this._children.forEach(function(child) {
+      if (!this.shouldStack(count + (options.addTab ? 1 : 0))) {
+        childrenToArrange.forEach(function(child) {
           child.removeClass("stacked")
         });
 
         this.topChild = null;
 
-        if (!this._children.length)
+        if (!childrenToArrange.length)
           return;
 
-        var arrangeOptions = Utils.copy(options);
-        Utils.extend(arrangeOptions, {
+        var arrangeOptions = Utils.extend({}, options, {
           columns: this._columns
         });
 
         // Items.arrange will rearrange the children, but also return an array
         // of the Rect's used.
 
-        var rects = Items.arrange(this._children, bb, arrangeOptions);
+        let result = Items.arrange(childrenToArrange, bb, arrangeOptions);
+        dropIndex = result.dropIndex;
+        if ("oldDropIndex" in options && options.oldDropIndex === dropIndex)
+          return dropIndex;
+        var rects = result.rects;
 
-        // first, find the right of the rightmost tab! luckily, they're in order.
-        var rightMostRight = 0;
-        if (UI.rtl) {
-          rightMostRight = rects[0].right;
-        } else {
-          for each (var rect in rects) {
-            if (rect.right > rightMostRight)
-              rightMostRight = rect.right;
-            else
-              break;
+        let index = 0;
+        let self = this;
+        childrenToArrange.forEach(function GroupItem_arrange_children_each(child, i) {
+          // If dropIndex spacing is active and this is a child after index,
+          // bump it up one so we actually use the correct rect
+          // (and skip one for the dropPos)
+          if (self._dropSpaceActive && index === dropIndex)
+            index++;
+          if (!child.locked.bounds) {
+            child.setBounds(rects[index], !options.animate);
+            child.setRotation(0);
+            if (options.z)
+              child.setZ(options.z);
           }
-        }
+          index++;
+        });
 
         this._isStacked = false;
       } else
         this._stackArrange(bb, options);
     }
 
     if (this._isStacked && !this.expanded) this.showExpandControl();
     else this.hideExpandControl();
+    
+    return dropIndex;
   },
 
   // ----------
   // Function: _stackArrange
   // Arranges the children in a stack.
   //
   // Parameters:
   //   bb - <Rect> to arrange within
@@ -1411,25 +1403,97 @@ GroupItem.prototype = Utils.extend(new I
     }
   },
 
   // ----------
   // Function: _addHandlers
   // Helper routine for the constructor; adds various event handlers to the container.
   _addHandlers: function GroupItem__addHandlers(container) {
     var self = this;
+    var dropIndex = false;
+    var dropSpaceTimer = null;
 
-    this.dropOptions.over = function() {
+    // When the _dropSpaceActive flag is turned on on a group, and a tab is
+    // dragged on top, a space will open up.
+    this._dropSpaceActive = false;
+
+    this.dropOptions.over = function GroupItem_dropOptions_over(event) {
       iQ(this.container).addClass("acceptsDrop");
     };
-    this.dropOptions.drop = function(event) {
+    this.dropOptions.move = function GroupItem_dropOptions_move(event) {
+      let oldDropIndex = dropIndex;
+      let dropPos = drag.info.item.getBounds().center();
+      let options = {dropPos: dropPos,
+                     addTab: self._dropSpaceActive && drag.info.item.parent != self,
+                     oldDropIndex: oldDropIndex};
+      newDropIndex = self.arrange(options);
+      // If this is a new drop index, start a timer!
+      if (newDropIndex !== oldDropIndex) {
+        dropIndex = newDropIndex;
+        if (this._dropSpaceActive)
+          return;
+          
+        if (dropSpaceTimer) {
+          clearTimeout(dropSpaceTimer);
+          dropSpaceTimer = null;
+        }
+
+        dropSpaceTimer = setTimeout(function GroupItem_arrange_evaluateDropSpace() {
+          // Note that dropIndex's scope is GroupItem__addHandlers, but
+          // newDropIndex's scope is GroupItem_dropOptions_move. Thus,
+          // dropIndex may change with other movement events before we come
+          // back and check this. If it's still the same dropIndex, activate
+          // drop space display!
+          if (dropIndex === newDropIndex) {
+            self._dropSpaceActive = true;
+            dropIndex = self.arrange({dropPos: dropPos,
+                                      addTab: drag.info.item.parent != self,
+                                      animate: true});
+          }
+          dropSpaceTimer = null;
+        }, 250);
+      }
+    };
+    this.dropOptions.drop = function GroupItem_dropOptions_drop(event) {
       iQ(this.container).removeClass("acceptsDrop");
-      this.add(drag.info.$el, {left:event.pageX, top:event.pageY});
+      let options = {};
+      if (this._dropSpaceActive)
+        this._dropSpaceActive = false;
+
+      if (dropSpaceTimer) {
+        clearTimeout(dropSpaceTimer);
+        dropSpaceTimer = null;
+        // If we drop this item before the timed rearrange was executed,
+        // we won't have an accurate dropIndex value. Get that now.
+        let dropPos = drag.info.item.getBounds().center();
+        dropIndex = self.arrange({dropPos: dropPos,
+                                  addTab: drag.info.item.parent != self,
+                                  animate: true});
+      }
+      if (dropIndex !== false)
+        options = {index: dropIndex}
+      this.add(drag.info.$el, options);
       GroupItems.setActiveGroupItem(this);
+      dropIndex = false;
     };
+    this.dropOptions.out = function GroupItem_dropOptions_out(event) {
+      dropIndex = false;
+      if (this._dropSpaceActive)
+        this._dropSpaceActive = false;
+
+      if (dropSpaceTimer) {
+        clearTimeout(dropSpaceTimer);
+        dropSpaceTimer = null;
+      }
+      self.arrange();
+      var groupItem = drag.info.item.parent;
+      if (groupItem)
+        groupItem.remove(drag.info.$el, {dontClose: true});
+      iQ(this.container).removeClass("acceptsDrop");
+    }
 
     if (!this.locked.bounds)
       this.draggable();
 
     this.droppable(true);
 
     this.$expander.click(function() {
       self.expand();
@@ -1878,17 +1942,17 @@ let GroupItems = {
     // orphan or not (make a new group if it's an orphan, add it to the group if it's
     // not)
     // 4. First group
     // 5. First orphan that's not the tab in question
     // 6. At this point there should be no groups or tabs (except for app tabs and the
     // tab in question): make a new group
 
     if (activeGroupItem) {
-      activeGroupItem.add(tabItem, null, options);
+      activeGroupItem.add(tabItem, options);
       return;
     }
 
     let orphanTabItem = this.getActiveOrphanTab();
     if (!orphanTabItem) {
       let targetGroupItem;
       // find first visible non-app tab in the tabbar.
       gBrowser.visibleTabs.some(function(tab) {
--- a/browser/base/content/tabview/items.js
+++ b/browser/base/content/tabview/items.js
@@ -174,16 +174,19 @@ Item.prototype = {
     iQ(this.container).data('item', this);
 
     // ___ drag
     this.dragOptions = {
       cancelClass: 'close stackExpander',
       start: function(e, ui) {
         if (this.isAGroupItem)
           GroupItems.setActiveGroupItem(this);
+        // if we start dragging a tab within a group, start with dropSpace on.
+        else if (this.parent != null)
+          this.parent._dropSpaceActive = true;
         drag.info = new Drag(this, e);
       },
       drag: function(e) {
         drag.info.drag(e);
       },
       stop: function() {
         drag.info.stop();
         drag.info = null;
@@ -195,17 +198,16 @@ Item.prototype = {
 
     // ___ drop
     this.dropOptions = {
       over: function() {},
       out: function() {
         var groupItem = drag.info.item.parent;
         if (groupItem)
           groupItem.remove(drag.info.$el, {dontClose: true});
-
         iQ(this.container).removeClass("acceptsDrop");
       },
       drop: function(event) {
         iQ(this.container).removeClass("acceptsDrop");
       },
       // Function: dropAcceptFunction
       // Given a DOM element, returns true if it should accept tabs being dropped on it.
       // Private to this file.
@@ -612,17 +614,16 @@ Item.prototype = {
             startSent = true;
           }
         }
         if (startSent) {
           // drag events
           var box = self.getBounds();
           box.left = startPos.x + (mouse.x - startMouse.x);
           box.top = startPos.y + (mouse.y - startMouse.y);
-
           self.setBounds(box, true);
 
           if (typeof self.dragOptions.drag == "function")
             self.dragOptions.drag.apply(self, [e]);
 
           // drop events
           var best = {
             dropTarget: null,
@@ -658,16 +659,21 @@ Item.prototype = {
             dropTarget = best.dropTarget;
 
             if (dropTarget) {
               dropOptions = dropTarget.dropOptions;
               if (dropOptions && typeof dropOptions.over == "function")
                 dropOptions.over.apply(dropTarget, [e]);
             }
           }
+          if (dropTarget) {
+            dropOptions = dropTarget.dropOptions;
+            if (dropOptions && typeof dropOptions.move == "function")
+              dropOptions.move.apply(dropTarget, [e]);
+          }
         }
 
         e.preventDefault();
       };
 
       // ___ mouseup
       var handleMouseUp = function(e) {
         iQ(gWindow)
@@ -914,35 +920,45 @@ let Items = {
   //   animate - whether to animate; default: true.
   //   z - the z index to set all the items; default: don't change z.
   //   return - if set to 'widthAndColumns', it'll return an object with the
   //     width of children and the columns.
   //   count - overrides the item count for layout purposes;
   //     default: the actual item count
   //   padding - pixels between each item
   //   columns - (int) a preset number of columns to use
+  //   dropPos - a <Point> which should have a one-tab space left open, used
+  //             when a tab is dragged over.
   //
   // Returns:
-  //   an object with the width value of the child items and the number of columns, 
-  //   if the return option is set to 'widthAndColumns'; otherwise the list of <Rect>s
+  //   By default, an object with two properties: `rects`, the list of <Rect>s,
+  //   and `dropIndex`, the index which a dragged tab should have if dropped
+  //   (null if no `dropPos` was specified);
+  //   If the `return` option is set to 'widthAndColumns', an object with the
+  //   width value of the child items (`childWidth`) and the number of columns
+  //   (`columns`) is returned.
   arrange: function Items_arrange(items, bounds, options) {
     if (typeof options == 'undefined')
       options = {};
 
     var animate = true;
     if (typeof options.animate != 'undefined')
       animate = options.animate;
     var immediately = !animate;
 
     var rects = [];
 
     var tabAspect = TabItems.tabHeight / TabItems.tabWidth;
     var count = options.count || (items ? items.length : 0);
-    if (!count)
-      return rects;
+    if (options.addTab)
+      count++;
+    if (!count) {
+      let dropIndex = (Utils.isPoint(options.dropPos)) ? 0 : null;
+      return {rects: rects, dropIndex: dropIndex};
+    }
 
     var columns = options.columns || 1;
     // We'll assume for the time being that all the items have the same styling
     // and that the margin is the same width around.
     var itemMargin = items && items.length ?
                        parseInt(iQ(items[0].container).css('margin-left')) : 0;
     var padding = itemMargin * 2;
     var yScale = 1.1; // to allow for titles
@@ -976,38 +992,44 @@ let Items = {
     let initialOffset = 0;
     if (UI.rtl) {
       initialOffset = bounds.width - tabWidth - padding;
     }
     var box = new Rect(bounds.left + initialOffset, bounds.top, tabWidth, tabHeight);
 
     var column = 0;
 
+    var dropIndex = false;
+    var dropRect = false;
+    if (Utils.isPoint(options.dropPos))
+      dropRect = new Rect(options.dropPos.x, options.dropPos.y, 1, 1);
     for (let a = 0; a < count; a++) {
+      // If we had a dropPos, see if this is where we should place it
+      if (dropRect) {
+        let activeBox = new Rect(box);
+        activeBox.inset(-itemMargin - 1, -itemMargin - 1);
+        // if the designated position (dropRect) is within the active box,
+        // this is where, if we drop the tab being dragged, it should land!
+        if (activeBox.contains(dropRect))
+          dropIndex = a;
+      }
+      
+      // record the box.
       rects.push(new Rect(box));
-      if (items && a < items.length) {
-        let item = items[a];
-        if (!item.locked.bounds) {
-          item.setBounds(box, immediately);
-          item.setRotation(0);
-          if (options.z)
-            item.setZ(options.z);
-        }
-      }
 
       box.left += (UI.rtl ? -1 : 1) * (box.width + padding);
       column++;
       if (column == columns) {
         box.left = bounds.left + initialOffset;
         box.top += (box.height * yScale) + padding;
         column = 0;
       }
     }
 
-    return rects;
+    return {rects: rects, dropIndex: dropIndex};
   },
 
   // ----------
   // Function: unsquish
   // Checks to see which items can now be unsquished.
   //
   // Parameters:
   //   pairs - an array of objects, each with two properties: item and bounds. The bounds are
--- a/browser/base/content/tabview/tabitems.js
+++ b/browser/base/content/tabview/tabitems.js
@@ -346,17 +346,17 @@ TabItem.prototype = Utils.extend(new Ite
       this.setBounds(tabData.bounds, true);
 
       if (Utils.isPoint(tabData.userSize))
         this.userSize = new Point(tabData.userSize);
 
       if (tabData.groupID) {
         var groupItem = GroupItems.groupItem(tabData.groupID);
         if (groupItem) {
-          groupItem.add(this, null, {immediately: true});
+          groupItem.add(this, {immediately: true});
 
           // if it matches the selected tab or no active tab and the browser 
           // tab is hidden, the active group item would be set.
           if (this.tab == gBrowser.selectedTab || 
               (!GroupItems.getActiveGroupItem() && !this.tab.hidden))
             GroupItems.setActiveGroupItem(this.parent);
         }
       }
--- a/browser/base/content/tabview/ui.js
+++ b/browser/base/content/tabview/ui.js
@@ -290,17 +290,17 @@ let UI = {
       bounds: box,
       immediately: true
     };
     let groupItem = new GroupItem([], options);
     let items = TabItems.getItems();
     items.forEach(function(item) {
       if (item.parent)
         item.parent.remove(item);
-      groupItem.add(item, null, {immediately: true});
+      groupItem.add(item, {immediately: true});
     });
     
     if (firstTime) {
       gPrefBranch.setBoolPref("experienced_first_run", true);
 
       let url = gPrefBranch.getCharPref("welcome_url");
       let newTab = gBrowser.loadOneTab(url, {inBackground: true});
       let newTabItem = newTab._tabViewTabItem;
--- a/browser/base/content/test/tabview/Makefile.in
+++ b/browser/base/content/test/tabview/Makefile.in
@@ -46,16 +46,17 @@ include $(topsrcdir)/config/rules.mk
 _BROWSER_FILES = \
                  browser_tabview_alltabs.js \
                  browser_tabview_apptabs.js \
                  browser_tabview_bug580412.js \
                  browser_tabview_bug586553.js \
                  browser_tabview_bug587043.js \
                  browser_tabview_bug587231.js \
                  browser_tabview_bug587351.js \
+                 browser_tabview_bug587503.js \
                  browser_tabview_bug587990.js \
                  browser_tabview_bug589324.js \
                  browser_tabview_bug590606.js \
                  browser_tabview_bug591706.js \
                  browser_tabview_bug594176.js \
                  browser_tabview_bug595191.js \
                  browser_tabview_bug595436.js \
                  browser_tabview_bug595518.js \
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/tabview/browser_tabview_bug587503.js
@@ -0,0 +1,245 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is bug 587503 test.
+ *
+ * The Initial Developer of the Original Code is
+ * Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ * Michael Yoshitaka Erlewine <mitcho@mitcho.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+function test() {
+  waitForExplicitFinish();
+
+  window.addEventListener("tabviewshown", onTabViewWindowLoaded, false);
+  if (TabView.isVisible())
+    onTabViewWindowLoaded();
+  else
+    TabView.show();
+}
+
+function onTabViewWindowLoaded() {
+  window.removeEventListener("tabviewshown", onTabViewWindowLoaded, false);
+
+  ok(TabView.isVisible(), "Tab View is visible");
+
+  let contentWindow = document.getElementById("tab-view").contentWindow;
+  let [originalTab] = gBrowser.visibleTabs;
+
+  let currentGroup = contentWindow.GroupItems.getActiveGroupItem();
+
+  // Create a group and make it active
+  let box = new contentWindow.Rect(100, 100, 370, 370);
+  let group = new contentWindow.GroupItem([], { bounds: box });
+  ok(group.isEmpty(), "This group is empty");
+  contentWindow.GroupItems.setActiveGroupItem(group);
+  
+  // Create a bunch of tabs in the group
+  let tabs = [];
+  tabs.push(gBrowser.loadOneTab("about:blank#0", {inBackground: true}));
+  tabs.push(gBrowser.loadOneTab("about:blank#1", {inBackground: true}));
+  tabs.push(gBrowser.loadOneTab("about:blank#2", {inBackground: true}));
+  tabs.push(gBrowser.loadOneTab("about:blank#3", {inBackground: true}));
+  tabs.push(gBrowser.loadOneTab("about:blank#4", {inBackground: true}));
+  tabs.push(gBrowser.loadOneTab("about:blank#5", {inBackground: true}));
+  tabs.push(gBrowser.loadOneTab("about:blank#6", {inBackground: true}));
+
+  ok(!group.shouldStack(group._children.length), "Group should not stack.");
+  
+  // PREPARE FINISH:
+  group.addSubscriber(group, "close", function() {
+    group.removeSubscriber(group, "close");
+
+    ok(group.isEmpty(), "The group is empty again");
+
+    contentWindow.GroupItems.setActiveGroupItem(currentGroup);
+    isnot(contentWindow.GroupItems.getActiveGroupItem(), null, "There is an active group");
+    is(gBrowser.tabs.length, 1, "There is only one tab left");
+    is(gBrowser.visibleTabs.length, 1, "There is also only one visible tab");
+
+    let onTabViewHidden = function() {
+      window.removeEventListener("tabviewhidden", onTabViewHidden, false);
+      finish();
+    };
+    window.addEventListener("tabviewhidden", onTabViewHidden, false);
+    gBrowser.selectedTab = originalTab;
+
+    TabView.hide();
+  });
+  
+  // STAGE 1: move the last tab to the third position
+  let currentTarget = tabs[6]._tabViewTabItem;
+  let currentPos = currentTarget.getBounds().center();
+  let targetPos = tabs[2]._tabViewTabItem.getBounds().center();
+  let vector = new contentWindow.Point(targetPos.x - currentPos.x,
+                                       targetPos.y - currentPos.y);
+  checkDropIndexAndDropSpace(currentTarget, group, vector.x, vector.y, contentWindow,
+                             function(index, dropSpaceActiveValues) {
+    // Now: 0, 1, 6, 2, 3, 4, 5
+    is(index, 2, "Tab 6 is now in the third position");
+    is(dropSpaceActiveValues[0], true, "dropSpace was always showing");
+
+    // STAGE 2: move the second tab to the end of the list
+    let currentTarget = tabs[1]._tabViewTabItem;
+    let currentPos = currentTarget.getBounds().center();
+    // pick a point in that empty bottom part of the group
+    let groupBounds = group.getBounds();
+    let bottomPos = new contentWindow.Point(
+                      (groupBounds.left + groupBounds.right) / 2,
+                      groupBounds.bottom - 15);
+    let vector = new contentWindow.Point(bottomPos.x - currentPos.x,
+                                         bottomPos.y - currentPos.y);
+    checkDropIndexAndDropSpace(currentTarget, group, vector.x, vector.y, contentWindow,
+                               function(index, dropSpaceActiveValues) {
+      // Now: 0, 6, 2, 3, 4, 5, 1
+      is(index, 6, "Tab 1 is now at the end of the group");
+      is(dropSpaceActiveValues[0], true, "dropSpace was always showing");
+    
+      // STAGE 3: move the fifth tab outside the group
+      // Note: there should be room below the active group...
+      let currentTarget = tabs[4]._tabViewTabItem;
+      let currentPos = currentTarget.getBounds().center();
+      // Pick a point below the group.
+      let belowPos = new contentWindow.Point(
+                        (groupBounds.left + groupBounds.right) / 2,
+                        groupBounds.bottom + 300);
+      let vector = new contentWindow.Point(belowPos.x - currentPos.x,
+                                           belowPos.y - currentPos.y);
+      checkDropIndexAndDropSpace(currentTarget, group, vector.x, vector.y, contentWindow,
+                                 function(index, dropSpaceActiveValues) {
+        // Now: 0, 6, 2, 3, 5, 1
+        is(index, -1, "Tab 5 is no longer in the group");
+        contentWindow.Utils.log('dropSpaceActiveValues',dropSpaceActiveValues);
+        is(dropSpaceActiveValues[0], true, "The group began by showing a dropSpace");
+        is(dropSpaceActiveValues[dropSpaceActiveValues.length - 1], false, "In the end, the group was not showing a dropSpace");
+        
+        // We wrap this in a setTimeout with 1000ms delay in order to wait for the
+        // tab to resize, as it does after we drop it in stage 3 outside of the group.
+        setTimeout(function() {
+          // STAGE 4: move the fifth tab back into the group, on the second row.
+          let currentTarget = tabs[4]._tabViewTabItem;
+          let currentPos = currentTarget.getBounds().center();
+          let targetPos = tabs[5]._tabViewTabItem.getBounds().center();
+          // contentWindow.Utils.log(targetPos, currentPos);
+          vector = new contentWindow.Point(targetPos.x - currentPos.x,
+                                               targetPos.y - currentPos.y);
+          // Call with time = 4000
+          checkDropIndexAndDropSpace(currentTarget, group, vector.x, vector.y, contentWindow,
+                                     function(index, dropSpaceActiveValues) {
+            // Now: 0, 6, 2, 3, 4, 5, 1
+            is(index, 4, "Tab 5 is back and again the fifth tab.");
+            contentWindow.Utils.log('dropSpaceActiveValues',dropSpaceActiveValues);
+            is(dropSpaceActiveValues[0], false, "The group began by not showing a dropSpace");
+            is(dropSpaceActiveValues[dropSpaceActiveValues.length - 1], true, "In the end, the group was showing a dropSpace");
+            
+            // Get rid of the group and its children
+            // The group close will trigger a finish().
+            group.closeAll();
+            group.closeHidden();
+          }, 6000, false);
+        },1000);
+        
+      });
+    
+    });
+
+  });
+}
+
+function simulateSlowDragDrop(srcElement, offsetX, offsetY, contentWindow, time) {
+  // enter drag mode
+  let dataTransfer;
+
+  // contentWindow.Utils.log('offset', offsetX, offsetY);
+  let bounds = srcElement.getBoundingClientRect();
+  // contentWindow.Utils.log('original center', bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
+
+  EventUtils.synthesizeMouse(
+    srcElement, 2, 2, { type: "mousedown" }, contentWindow);
+  let event = contentWindow.document.createEvent("DragEvents");
+  event.initDragEvent(
+    "dragenter", true, true, contentWindow, 0, 0, 0, 0, 0,
+    false, false, false, false, 1, null, dataTransfer);
+  srcElement.dispatchEvent(event);
+  
+  let steps = 20;
+  
+  // drag over
+  let moveIncremental = function moveIncremental(i, steps) {
+    // calculate how much to move
+    let offsetXDiff = Math.round(i * offsetX / steps) - Math.round((i - 1) * offsetX / steps);
+    let offsetYDiff = Math.round(i * offsetY / steps) - Math.round((i - 1) * offsetY / steps);
+    // contentWindow.Utils.log('step', offsetXDiff, offsetYDiff);
+    // simulate mousemove
+    EventUtils.synthesizeMouse(
+      srcElement, offsetXDiff + 2, offsetYDiff + 2,
+      { type: "mousemove" }, contentWindow);
+    // simulate dragover
+    let event = contentWindow.document.createEvent("DragEvents");
+    event.initDragEvent(
+      "dragover", true, true, contentWindow, 0, 0, 0, 0, 0,
+      false, false, false, false, 0, null, dataTransfer);
+    srcElement.dispatchEvent(event);
+    let bounds = srcElement.getBoundingClientRect();
+    // contentWindow.Utils.log(i, 'center', bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
+  };
+  for (let i = 1; i <= steps; i++)
+    setTimeout(moveIncremental, i / (steps + 1) * time, i, steps);
+
+  // drop
+  let finalDrop = function finalDrop() {
+    EventUtils.synthesizeMouseAtCenter(srcElement, { type: "mouseup" }, contentWindow);
+    event = contentWindow.document.createEvent("DragEvents");
+    event.initDragEvent(
+      "drop", true, true, contentWindow, 0, 0, 0, 0, 0,
+      false, false, false, false, 0, null, dataTransfer);
+    srcElement.dispatchEvent(event);
+    contentWindow.iQ(srcElement).css({border: 'green 1px solid'});
+  }
+  setTimeout(finalDrop, time);
+}
+
+function checkDropIndexAndDropSpace(item, group, offsetX, offsetY, contentWindow, callback, time) {
+  contentWindow.UI.setActiveTab(item);
+  let dropSpaceActiveValues = [];
+  let recordDropSpaceValue = function() {
+    dropSpaceActiveValues.push(group._dropSpaceActive);
+  };
+//  contentWindow.iQ(item.container).css({border: 'red 1px solid'});
+  let onDrop = function() {
+    item.container.removeEventListener('dragover', recordDropSpaceValue, false);
+    item.container.removeEventListener('drop', onDrop, false);
+    let index = group._children.indexOf(item);
+    callback(index, dropSpaceActiveValues);
+  };
+  item.container.addEventListener('dragover', recordDropSpaceValue, false);
+  item.container.addEventListener('drop', onDrop, false);
+  simulateSlowDragDrop(item.container, offsetX, offsetY, contentWindow, time || 1000);
+}