diff options
Diffstat (limited to 'browser/components/customizableui/DragPositionManager.jsm')
-rw-r--r-- | browser/components/customizableui/DragPositionManager.jsm | 420 |
1 files changed, 420 insertions, 0 deletions
diff --git a/browser/components/customizableui/DragPositionManager.jsm b/browser/components/customizableui/DragPositionManager.jsm new file mode 100644 index 000000000..1b4eb59dc --- /dev/null +++ b/browser/components/customizableui/DragPositionManager.jsm @@ -0,0 +1,420 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +Components.utils.import("resource:///modules/CustomizableUI.jsm"); + +var gManagers = new WeakMap(); + +const kPaletteId = "customization-palette"; +const kPlaceholderClass = "panel-customization-placeholder"; + +this.EXPORTED_SYMBOLS = ["DragPositionManager"]; + +function AreaPositionManager(aContainer) { + // Caching the direction and bounds of the container for quick access later: + let window = aContainer.ownerGlobal; + this._dir = window.getComputedStyle(aContainer).direction; + let containerRect = aContainer.getBoundingClientRect(); + this._containerInfo = { + left: containerRect.left, + right: containerRect.right, + top: containerRect.top, + width: containerRect.width + }; + this._inPanel = aContainer.id == CustomizableUI.AREA_PANEL; + this._horizontalDistance = null; + this.update(aContainer); +} + +AreaPositionManager.prototype = { + _nodePositionStore: null, + _wideCache: null, + + update: function(aContainer) { + this._nodePositionStore = new WeakMap(); + this._wideCache = new Set(); + let last = null; + let singleItemHeight; + for (let child of aContainer.children) { + if (child.hidden) { + continue; + } + let isNodeWide = this._checkIfWide(child); + if (isNodeWide) { + this._wideCache.add(child.id); + } + let coordinates = this._lazyStoreGet(child); + // We keep a baseline horizontal distance between non-wide nodes around + // for use when we can't compare with previous/next nodes + if (!this._horizontalDistance && last && !isNodeWide) { + this._horizontalDistance = coordinates.left - last.left; + } + // We also keep the basic height of non-wide items for use below: + if (!isNodeWide && !singleItemHeight) { + singleItemHeight = coordinates.height; + } + last = !isNodeWide ? coordinates : null; + } + if (this._inPanel) { + this._heightToWidthFactor = CustomizableUI.PANEL_COLUMN_COUNT; + } else { + this._heightToWidthFactor = this._containerInfo.width / singleItemHeight; + } + }, + + /** + * Find the closest node in the container given the coordinates. + * "Closest" is defined in a somewhat strange manner: we prefer nodes + * which are in the same row over nodes that are in a different row. + * In order to implement this, we use a weighted cartesian distance + * where dy is more heavily weighted by a factor corresponding to the + * ratio between the container's width and the height of its elements. + */ + find: function(aContainer, aX, aY, aDraggedItemId) { + let closest = null; + let minCartesian = Number.MAX_VALUE; + let containerX = this._containerInfo.left; + let containerY = this._containerInfo.top; + for (let node of aContainer.children) { + let coordinates = this._lazyStoreGet(node); + let offsetX = coordinates.x - containerX; + let offsetY = coordinates.y - containerY; + let hDiff = offsetX - aX; + let vDiff = offsetY - aY; + // For wide widgets, we're always going to be further from the center + // horizontally. Compensate: + if (this.isWide(node)) { + hDiff /= CustomizableUI.PANEL_COLUMN_COUNT; + } + // Then compensate for the height/width ratio so that we prefer items + // which are in the same row: + hDiff /= this._heightToWidthFactor; + + let cartesianDiff = hDiff * hDiff + vDiff * vDiff; + if (cartesianDiff < minCartesian) { + minCartesian = cartesianDiff; + closest = node; + } + } + + // Now correct this node based on what we're dragging + if (closest) { + let doc = aContainer.ownerDocument; + let draggedItem = doc.getElementById(aDraggedItemId); + // If dragging a wide item, always pick the first item in a row: + if (this._inPanel && draggedItem && + draggedItem.classList.contains(CustomizableUI.WIDE_PANEL_CLASS)) { + return this._firstInRow(closest); + } + let targetBounds = this._lazyStoreGet(closest); + let farSide = this._dir == "ltr" ? "right" : "left"; + let outsideX = targetBounds[farSide]; + // Check if we're closer to the next target than to this one: + // Only move if we're not targeting a node in a different row: + if (aY > targetBounds.top && aY < targetBounds.bottom) { + if ((this._dir == "ltr" && aX > outsideX) || + (this._dir == "rtl" && aX < outsideX)) { + return closest.nextSibling || aContainer; + } + } + } + return closest; + }, + + /** + * "Insert" a "placeholder" by shifting the subsequent children out of the + * way. We go through all the children, and shift them based on the position + * they would have if we had inserted something before aBefore. We use CSS + * transforms for this, which are CSS transitioned. + */ + insertPlaceholder: function(aContainer, aBefore, aWide, aSize, aIsFromThisArea) { + let isShifted = false; + let shiftDown = aWide; + for (let child of aContainer.children) { + // Don't need to shift hidden nodes: + if (child.getAttribute("hidden") == "true") { + continue; + } + // If this is the node before which we're inserting, start shifting + // everything that comes after. One exception is inserting at the end + // of the menupanel, in which case we do not shift the placeholders: + if (child == aBefore && !child.classList.contains(kPlaceholderClass)) { + isShifted = true; + // If the node before which we're inserting is wide, we should + // shift everything one row down: + if (!shiftDown && this.isWide(child)) { + shiftDown = true; + } + } + // If we're moving items before a wide node that were already there, + // it's possible it's not necessary to shift nodes + // including & after the wide node. + if (this.__undoShift) { + isShifted = false; + } + if (isShifted) { + // Conversely, if we're adding something before a wide node, for + // simplicity's sake we move everything including the wide node down: + if (this.__moveDown) { + shiftDown = true; + } + if (aIsFromThisArea && !this._lastPlaceholderInsertion) { + child.setAttribute("notransition", "true"); + } + // Determine the CSS transform based on the next node: + child.style.transform = this._getNextPos(child, shiftDown, aSize); + } else { + // If we're not shifting this node, reset the transform + child.style.transform = ""; + } + } + if (aContainer.lastChild && aIsFromThisArea && + !this._lastPlaceholderInsertion) { + // Flush layout: + aContainer.lastChild.getBoundingClientRect(); + // then remove all the [notransition] + for (let child of aContainer.children) { + child.removeAttribute("notransition"); + } + } + delete this.__moveDown; + delete this.__undoShift; + this._lastPlaceholderInsertion = aBefore; + }, + + isWide: function(aNode) { + return this._wideCache.has(aNode.id); + }, + + _checkIfWide: function(aNode) { + return this._inPanel && aNode && aNode.firstChild && + aNode.firstChild.classList.contains(CustomizableUI.WIDE_PANEL_CLASS); + }, + + /** + * Reset all the transforms in this container, optionally without + * transitioning them. + * @param aContainer the container in which to reset transforms + * @param aNoTransition if truthy, adds a notransition attribute to the node + * while resetting the transform. + */ + clearPlaceholders: function(aContainer, aNoTransition) { + for (let child of aContainer.children) { + if (aNoTransition) { + child.setAttribute("notransition", true); + } + child.style.transform = ""; + if (aNoTransition) { + // Need to force a reflow otherwise this won't work. + child.getBoundingClientRect(); + child.removeAttribute("notransition"); + } + } + // We snapped back, so we can assume there's no more + // "last" placeholder insertion point to keep track of. + if (aNoTransition) { + this._lastPlaceholderInsertion = null; + } + }, + + _getNextPos: function(aNode, aShiftDown, aSize) { + // Shifting down is easy: + if (this._inPanel && aShiftDown) { + return "translate(0, " + aSize.height + "px)"; + } + return this._diffWithNext(aNode, aSize); + }, + + _diffWithNext: function(aNode, aSize) { + let xDiff; + let yDiff = null; + let nodeBounds = this._lazyStoreGet(aNode); + let side = this._dir == "ltr" ? "left" : "right"; + let next = this._getVisibleSiblingForDirection(aNode, "next"); + // First we determine the transform along the x axis. + // Usually, there will be a next node to base this on: + if (next) { + let otherBounds = this._lazyStoreGet(next); + xDiff = otherBounds[side] - nodeBounds[side]; + // If the next node is a wide item in the panel, check if we could maybe + // just move further out in the same row, without snapping to the next + // one. This happens, for example, if moving an item that's before a wide + // node within its own row of items. There will be space to drop this + // item within the row, and the rest of the items do not need to shift. + if (this.isWide(next)) { + let otherXDiff = this._moveNextBasedOnPrevious(aNode, nodeBounds, + this._firstInRow(aNode)); + // If this has the same sign as our original shift, we're still + // snapping to the start of the row. In this case, we should move + // everything after us a row down, so as not to display two nodes on + // top of each other: + // (we would be able to get away with checking for equality instead of + // equal signs here, but one of these is based on the x coordinate of + // the first item in row N and one on that for row N - 1, so this is + // safer, as their margins might differ) + if ((otherXDiff < 0) == (xDiff < 0)) { + this.__moveDown = true; + } else { + // Otherwise, we succeeded and can move further out. This also means + // we can stop shifting the rest of the content: + xDiff = otherXDiff; + this.__undoShift = true; + } + } else { + // We set this explicitly because otherwise some strange difference + // between the height and the actual difference between line creeps in + // and messes with alignments + yDiff = otherBounds.top - nodeBounds.top; + } + } else { + // We don't have a sibling whose position we can use. First, let's see + // if we're also the first item (which complicates things): + let firstNode = this._firstInRow(aNode); + if (aNode == firstNode) { + // Maybe we stored the horizontal distance between non-wide nodes, + // if not, we'll use the width of the incoming node as a proxy: + xDiff = this._horizontalDistance || aSize.width; + } else { + // If not, we should be able to get the distance to the previous node + // and use the inverse, unless there's no room for another node (ie we + // are the last node and there's no room for another one) + xDiff = this._moveNextBasedOnPrevious(aNode, nodeBounds, firstNode); + } + } + + // If we've not determined the vertical difference yet, check it here + if (yDiff === null) { + // If the next node is behind rather than in front, we must have moved + // vertically: + if ((xDiff > 0 && this._dir == "rtl") || (xDiff < 0 && this._dir == "ltr")) { + yDiff = aSize.height; + } else { + // Otherwise, we haven't + yDiff = 0; + } + } + return "translate(" + xDiff + "px, " + yDiff + "px)"; + }, + + /** + * Helper function to find the transform a node if there isn't a next node + * to base that on. + * @param aNode the node to transform + * @param aNodeBounds the bounding rect info of this node + * @param aFirstNodeInRow the first node in aNode's row + */ + _moveNextBasedOnPrevious: function(aNode, aNodeBounds, aFirstNodeInRow) { + let next = this._getVisibleSiblingForDirection(aNode, "previous"); + let otherBounds = this._lazyStoreGet(next); + let side = this._dir == "ltr" ? "left" : "right"; + let xDiff = aNodeBounds[side] - otherBounds[side]; + // If, however, this means we move outside the container's box + // (i.e. the row in which this item is placed is full) + // we should move it to align with the first item in the next row instead + let bound = this._containerInfo[this._dir == "ltr" ? "right" : "left"]; + if ((this._dir == "ltr" && xDiff + aNodeBounds.right > bound) || + (this._dir == "rtl" && xDiff + aNodeBounds.left < bound)) { + xDiff = this._lazyStoreGet(aFirstNodeInRow)[side] - aNodeBounds[side]; + } + return xDiff; + }, + + /** + * Get position details from our cache. If the node is not yet cached, get its position + * information and cache it now. + * @param aNode the node whose position info we want + * @return the position info + */ + _lazyStoreGet: function(aNode) { + let rect = this._nodePositionStore.get(aNode); + if (!rect) { + // getBoundingClientRect() returns a DOMRect that is live, meaning that + // as the element moves around, the rects values change. We don't want + // that - we want a snapshot of what the rect values are right at this + // moment, and nothing else. So we have to clone the values. + let clientRect = aNode.getBoundingClientRect(); + rect = { + left: clientRect.left, + right: clientRect.right, + width: clientRect.width, + height: clientRect.height, + top: clientRect.top, + bottom: clientRect.bottom, + }; + rect.x = rect.left + rect.width / 2; + rect.y = rect.top + rect.height / 2; + Object.freeze(rect); + this._nodePositionStore.set(aNode, rect); + } + return rect; + }, + + _firstInRow: function(aNode) { + // XXXmconley: I'm not entirely sure why we need to take the floor of these + // values - it looks like, periodically, we're getting fractional pixels back + // from lazyStoreGet. I've filed bug 994247 to investigate. + let bound = Math.floor(this._lazyStoreGet(aNode).top); + let rv = aNode; + let prev; + while (rv && (prev = this._getVisibleSiblingForDirection(rv, "previous"))) { + if (Math.floor(this._lazyStoreGet(prev).bottom) <= bound) { + return rv; + } + rv = prev; + } + return rv; + }, + + _getVisibleSiblingForDirection: function(aNode, aDirection) { + let rv = aNode; + do { + rv = rv[aDirection + "Sibling"]; + } while (rv && rv.getAttribute("hidden") == "true") + return rv; + } +} + +var DragPositionManager = { + start: function(aWindow) { + let areas = CustomizableUI.areas.filter((area) => CustomizableUI.getAreaType(area) != "toolbar"); + areas = areas.map((area) => CustomizableUI.getCustomizeTargetForArea(area, aWindow)); + areas.push(aWindow.document.getElementById(kPaletteId)); + for (let areaNode of areas) { + let positionManager = gManagers.get(areaNode); + if (positionManager) { + positionManager.update(areaNode); + } else { + gManagers.set(areaNode, new AreaPositionManager(areaNode)); + } + } + }, + + add: function(aWindow, aArea, aContainer) { + if (CustomizableUI.getAreaType(aArea) != "toolbar") { + return; + } + + gManagers.set(aContainer, new AreaPositionManager(aContainer)); + }, + + remove: function(aWindow, aArea, aContainer) { + if (CustomizableUI.getAreaType(aArea) != "toolbar") { + return; + } + + gManagers.delete(aContainer); + }, + + stop: function() { + gManagers = new WeakMap(); + }, + + getManagerForArea: function(aArea) { + return gManagers.get(aArea); + } +}; + +Object.freeze(DragPositionManager); |