1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7Components.utils.import("resource:///modules/CustomizableUI.jsm");
8
9var gManagers = new WeakMap();
10
11const kPaletteId = "customization-palette";
12const kPlaceholderClass = "panel-customization-placeholder";
13
14this.EXPORTED_SYMBOLS = ["DragPositionManager"];
15
16function AreaPositionManager(aContainer) {
17  // Caching the direction and bounds of the container for quick access later:
18  let window = aContainer.ownerGlobal;
19  this._dir = window.getComputedStyle(aContainer).direction;
20  let containerRect = aContainer.getBoundingClientRect();
21  this._containerInfo = {
22    left: containerRect.left,
23    right: containerRect.right,
24    top: containerRect.top,
25    width: containerRect.width
26  };
27  this._inPanel = aContainer.id == CustomizableUI.AREA_PANEL;
28  this._horizontalDistance = null;
29  this.update(aContainer);
30}
31
32AreaPositionManager.prototype = {
33  _nodePositionStore: null,
34  _wideCache: null,
35
36  update: function(aContainer) {
37    this._nodePositionStore = new WeakMap();
38    this._wideCache = new Set();
39    let last = null;
40    let singleItemHeight;
41    for (let child of aContainer.children) {
42      if (child.hidden) {
43        continue;
44      }
45      let isNodeWide = this._checkIfWide(child);
46      if (isNodeWide) {
47        this._wideCache.add(child.id);
48      }
49      let coordinates = this._lazyStoreGet(child);
50      // We keep a baseline horizontal distance between non-wide nodes around
51      // for use when we can't compare with previous/next nodes
52      if (!this._horizontalDistance && last && !isNodeWide) {
53        this._horizontalDistance = coordinates.left - last.left;
54      }
55      // We also keep the basic height of non-wide items for use below:
56      if (!isNodeWide && !singleItemHeight) {
57        singleItemHeight = coordinates.height;
58      }
59      last = !isNodeWide ? coordinates : null;
60    }
61    if (this._inPanel) {
62      this._heightToWidthFactor = CustomizableUI.PANEL_COLUMN_COUNT;
63    } else {
64      this._heightToWidthFactor = this._containerInfo.width / singleItemHeight;
65    }
66  },
67
68  /**
69   * Find the closest node in the container given the coordinates.
70   * "Closest" is defined in a somewhat strange manner: we prefer nodes
71   * which are in the same row over nodes that are in a different row.
72   * In order to implement this, we use a weighted cartesian distance
73   * where dy is more heavily weighted by a factor corresponding to the
74   * ratio between the container's width and the height of its elements.
75   */
76  find: function(aContainer, aX, aY, aDraggedItemId) {
77    let closest = null;
78    let minCartesian = Number.MAX_VALUE;
79    let containerX = this._containerInfo.left;
80    let containerY = this._containerInfo.top;
81    for (let node of aContainer.children) {
82      let coordinates = this._lazyStoreGet(node);
83      let offsetX = coordinates.x - containerX;
84      let offsetY = coordinates.y - containerY;
85      let hDiff = offsetX - aX;
86      let vDiff = offsetY - aY;
87      // For wide widgets, we're always going to be further from the center
88      // horizontally. Compensate:
89      if (this.isWide(node)) {
90        hDiff /= CustomizableUI.PANEL_COLUMN_COUNT;
91      }
92      // Then compensate for the height/width ratio so that we prefer items
93      // which are in the same row:
94      hDiff /= this._heightToWidthFactor;
95
96      let cartesianDiff = hDiff * hDiff + vDiff * vDiff;
97      if (cartesianDiff < minCartesian) {
98        minCartesian = cartesianDiff;
99        closest = node;
100      }
101    }
102
103    // Now correct this node based on what we're dragging
104    if (closest) {
105      let doc = aContainer.ownerDocument;
106      let draggedItem = doc.getElementById(aDraggedItemId);
107      // If dragging a wide item, always pick the first item in a row:
108      if (this._inPanel && draggedItem &&
109          draggedItem.classList.contains(CustomizableUI.WIDE_PANEL_CLASS)) {
110        return this._firstInRow(closest);
111      }
112      let targetBounds = this._lazyStoreGet(closest);
113      let farSide = this._dir == "ltr" ? "right" : "left";
114      let outsideX = targetBounds[farSide];
115      // Check if we're closer to the next target than to this one:
116      // Only move if we're not targeting a node in a different row:
117      if (aY > targetBounds.top && aY < targetBounds.bottom) {
118        if ((this._dir == "ltr" && aX > outsideX) ||
119            (this._dir == "rtl" && aX < outsideX)) {
120          return closest.nextSibling || aContainer;
121        }
122      }
123    }
124    return closest;
125  },
126
127  /**
128   * "Insert" a "placeholder" by shifting the subsequent children out of the
129   * way. We go through all the children, and shift them based on the position
130   * they would have if we had inserted something before aBefore. We use CSS
131   * transforms for this, which are CSS transitioned.
132   */
133  insertPlaceholder: function(aContainer, aBefore, aWide, aSize, aIsFromThisArea) {
134    let isShifted = false;
135    let shiftDown = aWide;
136    for (let child of aContainer.children) {
137      // Don't need to shift hidden nodes:
138      if (child.getAttribute("hidden") == "true") {
139        continue;
140      }
141      // If this is the node before which we're inserting, start shifting
142      // everything that comes after. One exception is inserting at the end
143      // of the menupanel, in which case we do not shift the placeholders:
144      if (child == aBefore && !child.classList.contains(kPlaceholderClass)) {
145        isShifted = true;
146        // If the node before which we're inserting is wide, we should
147        // shift everything one row down:
148        if (!shiftDown && this.isWide(child)) {
149          shiftDown = true;
150        }
151      }
152      // If we're moving items before a wide node that were already there,
153      // it's possible it's not necessary to shift nodes
154      // including & after the wide node.
155      if (this.__undoShift) {
156        isShifted = false;
157      }
158      if (isShifted) {
159        // Conversely, if we're adding something before a wide node, for
160        // simplicity's sake we move everything including the wide node down:
161        if (this.__moveDown) {
162          shiftDown = true;
163        }
164        if (aIsFromThisArea && !this._lastPlaceholderInsertion) {
165          child.setAttribute("notransition", "true");
166        }
167        // Determine the CSS transform based on the next node:
168        child.style.transform = this._getNextPos(child, shiftDown, aSize);
169      } else {
170        // If we're not shifting this node, reset the transform
171        child.style.transform = "";
172      }
173    }
174    if (aContainer.lastChild && aIsFromThisArea &&
175        !this._lastPlaceholderInsertion) {
176      // Flush layout:
177      aContainer.lastChild.getBoundingClientRect();
178      // then remove all the [notransition]
179      for (let child of aContainer.children) {
180        child.removeAttribute("notransition");
181      }
182    }
183    delete this.__moveDown;
184    delete this.__undoShift;
185    this._lastPlaceholderInsertion = aBefore;
186  },
187
188  isWide: function(aNode) {
189    return this._wideCache.has(aNode.id);
190  },
191
192  _checkIfWide: function(aNode) {
193    return this._inPanel && aNode && aNode.firstChild &&
194           aNode.firstChild.classList.contains(CustomizableUI.WIDE_PANEL_CLASS);
195  },
196
197  /**
198   * Reset all the transforms in this container, optionally without
199   * transitioning them.
200   * @param aContainer    the container in which to reset transforms
201   * @param aNoTransition if truthy, adds a notransition attribute to the node
202   *                      while resetting the transform.
203   */
204  clearPlaceholders: function(aContainer, aNoTransition) {
205    for (let child of aContainer.children) {
206      if (aNoTransition) {
207        child.setAttribute("notransition", true);
208      }
209      child.style.transform = "";
210      if (aNoTransition) {
211        // Need to force a reflow otherwise this won't work.
212        child.getBoundingClientRect();
213        child.removeAttribute("notransition");
214      }
215    }
216    // We snapped back, so we can assume there's no more
217    // "last" placeholder insertion point to keep track of.
218    if (aNoTransition) {
219      this._lastPlaceholderInsertion = null;
220    }
221  },
222
223  _getNextPos: function(aNode, aShiftDown, aSize) {
224    // Shifting down is easy:
225    if (this._inPanel && aShiftDown) {
226      return "translate(0, " + aSize.height + "px)";
227    }
228    return this._diffWithNext(aNode, aSize);
229  },
230
231  _diffWithNext: function(aNode, aSize) {
232    let xDiff;
233    let yDiff = null;
234    let nodeBounds = this._lazyStoreGet(aNode);
235    let side = this._dir == "ltr" ? "left" : "right";
236    let next = this._getVisibleSiblingForDirection(aNode, "next");
237    // First we determine the transform along the x axis.
238    // Usually, there will be a next node to base this on:
239    if (next) {
240      let otherBounds = this._lazyStoreGet(next);
241      xDiff = otherBounds[side] - nodeBounds[side];
242      // If the next node is a wide item in the panel, check if we could maybe
243      // just move further out in the same row, without snapping to the next
244      // one. This happens, for example, if moving an item that's before a wide
245      // node within its own row of items. There will be space to drop this
246      // item within the row, and the rest of the items do not need to shift.
247      if (this.isWide(next)) {
248        let otherXDiff = this._moveNextBasedOnPrevious(aNode, nodeBounds,
249                                                       this._firstInRow(aNode));
250        // If this has the same sign as our original shift, we're still
251        // snapping to the start of the row. In this case, we should move
252        // everything after us a row down, so as not to display two nodes on
253        // top of each other:
254        // (we would be able to get away with checking for equality instead of
255        //  equal signs here, but one of these is based on the x coordinate of
256        //  the first item in row N and one on that for row N - 1, so this is
257        //  safer, as their margins might differ)
258        if ((otherXDiff < 0) == (xDiff < 0)) {
259          this.__moveDown = true;
260        } else {
261          // Otherwise, we succeeded and can move further out. This also means
262          // we can stop shifting the rest of the content:
263          xDiff = otherXDiff;
264          this.__undoShift = true;
265        }
266      } else {
267        // We set this explicitly because otherwise some strange difference
268        // between the height and the actual difference between line creeps in
269        // and messes with alignments
270        yDiff = otherBounds.top - nodeBounds.top;
271      }
272    } else {
273      // We don't have a sibling whose position we can use. First, let's see
274      // if we're also the first item (which complicates things):
275      let firstNode = this._firstInRow(aNode);
276      if (aNode == firstNode) {
277        // Maybe we stored the horizontal distance between non-wide nodes,
278        // if not, we'll use the width of the incoming node as a proxy:
279        xDiff = this._horizontalDistance || aSize.width;
280      } else {
281        // If not, we should be able to get the distance to the previous node
282        // and use the inverse, unless there's no room for another node (ie we
283        // are the last node and there's no room for another one)
284        xDiff = this._moveNextBasedOnPrevious(aNode, nodeBounds, firstNode);
285      }
286    }
287
288    // If we've not determined the vertical difference yet, check it here
289    if (yDiff === null) {
290      // If the next node is behind rather than in front, we must have moved
291      // vertically:
292      if ((xDiff > 0 && this._dir == "rtl") || (xDiff < 0 && this._dir == "ltr")) {
293        yDiff = aSize.height;
294      } else {
295        // Otherwise, we haven't
296        yDiff = 0;
297      }
298    }
299    return "translate(" + xDiff + "px, " + yDiff + "px)";
300  },
301
302  /**
303   * Helper function to find the transform a node if there isn't a next node
304   * to base that on.
305   * @param aNode           the node to transform
306   * @param aNodeBounds     the bounding rect info of this node
307   * @param aFirstNodeInRow the first node in aNode's row
308   */
309  _moveNextBasedOnPrevious: function(aNode, aNodeBounds, aFirstNodeInRow) {
310    let next = this._getVisibleSiblingForDirection(aNode, "previous");
311    let otherBounds = this._lazyStoreGet(next);
312    let side = this._dir == "ltr" ? "left" : "right";
313    let xDiff = aNodeBounds[side] - otherBounds[side];
314    // If, however, this means we move outside the container's box
315    // (i.e. the row in which this item is placed is full)
316    // we should move it to align with the first item in the next row instead
317    let bound = this._containerInfo[this._dir == "ltr" ? "right" : "left"];
318    if ((this._dir == "ltr" && xDiff + aNodeBounds.right > bound) ||
319        (this._dir == "rtl" && xDiff + aNodeBounds.left < bound)) {
320      xDiff = this._lazyStoreGet(aFirstNodeInRow)[side] - aNodeBounds[side];
321    }
322    return xDiff;
323  },
324
325  /**
326   * Get position details from our cache. If the node is not yet cached, get its position
327   * information and cache it now.
328   * @param aNode  the node whose position info we want
329   * @return the position info
330   */
331  _lazyStoreGet: function(aNode) {
332    let rect = this._nodePositionStore.get(aNode);
333    if (!rect) {
334      // getBoundingClientRect() returns a DOMRect that is live, meaning that
335      // as the element moves around, the rects values change. We don't want
336      // that - we want a snapshot of what the rect values are right at this
337      // moment, and nothing else. So we have to clone the values.
338      let clientRect = aNode.getBoundingClientRect();
339      rect = {
340        left: clientRect.left,
341        right: clientRect.right,
342        width: clientRect.width,
343        height: clientRect.height,
344        top: clientRect.top,
345        bottom: clientRect.bottom,
346      };
347      rect.x = rect.left + rect.width / 2;
348      rect.y = rect.top + rect.height / 2;
349      Object.freeze(rect);
350      this._nodePositionStore.set(aNode, rect);
351    }
352    return rect;
353  },
354
355  _firstInRow: function(aNode) {
356    // XXXmconley: I'm not entirely sure why we need to take the floor of these
357    // values - it looks like, periodically, we're getting fractional pixels back
358    // from lazyStoreGet. I've filed bug 994247 to investigate.
359    let bound = Math.floor(this._lazyStoreGet(aNode).top);
360    let rv = aNode;
361    let prev;
362    while (rv && (prev = this._getVisibleSiblingForDirection(rv, "previous"))) {
363      if (Math.floor(this._lazyStoreGet(prev).bottom) <= bound) {
364        return rv;
365      }
366      rv = prev;
367    }
368    return rv;
369  },
370
371  _getVisibleSiblingForDirection: function(aNode, aDirection) {
372    let rv = aNode;
373    do {
374      rv = rv[aDirection + "Sibling"];
375    } while (rv && rv.getAttribute("hidden") == "true")
376    return rv;
377  }
378}
379
380var DragPositionManager = {
381  start: function(aWindow) {
382    let areas = CustomizableUI.areas.filter((area) => CustomizableUI.getAreaType(area) != "toolbar");
383    areas = areas.map((area) => CustomizableUI.getCustomizeTargetForArea(area, aWindow));
384    areas.push(aWindow.document.getElementById(kPaletteId));
385    for (let areaNode of areas) {
386      let positionManager = gManagers.get(areaNode);
387      if (positionManager) {
388        positionManager.update(areaNode);
389      } else {
390        gManagers.set(areaNode, new AreaPositionManager(areaNode));
391      }
392    }
393  },
394
395  add: function(aWindow, aArea, aContainer) {
396    if (CustomizableUI.getAreaType(aArea) != "toolbar") {
397      return;
398    }
399
400    gManagers.set(aContainer, new AreaPositionManager(aContainer));
401  },
402
403  remove: function(aWindow, aArea, aContainer) {
404    if (CustomizableUI.getAreaType(aArea) != "toolbar") {
405      return;
406    }
407
408    gManagers.delete(aContainer);
409  },
410
411  stop: function() {
412    gManagers = new WeakMap();
413  },
414
415  getManagerForArea: function(aArea) {
416    return gManagers.get(aArea);
417  }
418};
419
420Object.freeze(DragPositionManager);
421