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