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