DragPositionManager.sys.mjs (17378B)
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 /** 6 * A cache of AreaPositionManagers weakly mapped to customization area nodes. 7 * 8 * @type {WeakMap<DOMNode, AreaPositionManager>} 9 */ 10 var gManagers = new WeakMap(); 11 12 const kPaletteId = "customization-palette"; 13 14 /** 15 * An AreaPositionManager is used to power the animated drag-and-drop grid 16 * behaviour of each customizable area (toolbars, the palette, the overflow 17 * panel) while in customize mode. Each customizable area has its own 18 * AreaPositionManager, per browser window. 19 */ 20 class AreaPositionManager { 21 /** 22 * True if the container is oriented from right-to-left. 23 * 24 * @type {boolean} 25 */ 26 #rtl = false; 27 28 /** 29 * A DOMRectReadOnly for the bounding client rect for the container, 30 * collected once during construction. 31 * 32 * @type {DOMRectReadOnly|null} 33 */ 34 #containerInfo = null; 35 36 /** 37 * The calculated horizontal distance between the first two visible child 38 * nodes of the container. 39 * 40 * @type {number} 41 */ 42 #horizontalDistance = 0; 43 44 /** 45 * The ratio of the width of the container and the height of the first 46 * visible child node. This is used in the weighted cartesian distance 47 * calculation used in the AreaPositionManager.find method. 48 * 49 * @see AreaPositionManager.find 50 * @type {number} 51 */ 52 #heightToWidthFactor = 0; 53 54 /** 55 * Constructs an instance of AreaPositionManager for a customizable area. 56 * 57 * @param {DOMNode} aContainer 58 * The customizable area container node for which drag-and-drop animations 59 * are to be calculated for. 60 */ 61 constructor(aContainer) { 62 // Caching the direction and bounds of the container for quick access later: 63 this.#rtl = aContainer.ownerGlobal.RTL_UI; 64 this.#containerInfo = DOMRectReadOnly.fromRect( 65 aContainer.getBoundingClientRect() 66 ); 67 this.update(aContainer); 68 } 69 70 /** 71 * A cache of container child node size and position data. 72 * 73 * @type {WeakMap<DOMNode, DOMRectReadOnly>} 74 */ 75 #nodePositionStore = new WeakMap(); 76 77 /** 78 * The child node immediately after the most recently placed placeholder. May 79 * be null if no placeholder has been inserted yet, or if the placeholder is 80 * at the end of the container. 81 * 82 * @type {DOMNode|null} 83 */ 84 #lastPlaceholderInsertion = null; 85 86 /** 87 * Iterates the visible children of the container, sampling their bounding 88 * client rects and storing them in a local cache. Also collects and stores 89 * metrics like the horizontal distance between the first two children, 90 * the height of the first item, and a ratio between the width of the 91 * container and the height of the first child item. 92 * 93 * @param {DOMNode} aContainer 94 * The container node to collect the measurements for. 95 */ 96 update(aContainer) { 97 let last = null; 98 let singleItemHeight; 99 for (let child of aContainer.children) { 100 if (child.hidden) { 101 continue; 102 } 103 let coordinates = this.#lazyStoreGet(child); 104 // We keep a baseline horizontal distance between nodes around 105 // for use when we can't compare with previous/next nodes 106 if (!this.#horizontalDistance && last) { 107 this.#horizontalDistance = coordinates.left - last.left; 108 } 109 // We also keep the basic height of items for use below: 110 if (!singleItemHeight) { 111 singleItemHeight = coordinates.height; 112 } 113 last = coordinates; 114 } 115 this.#heightToWidthFactor = this.#containerInfo.width / singleItemHeight; 116 } 117 118 /** 119 * Find the closest node in the container given the coordinates. 120 * "Closest" is defined in a somewhat strange manner: we prefer nodes 121 * which are in the same row over nodes that are in a different row. 122 * In order to implement this, we use a weighted cartesian distance 123 * where dy is more heavily weighted by a factor corresponding to the 124 * ratio between the container's width and the height of its elements. 125 * 126 * @param {DOMNode} aContainer 127 * The container element that contains one or more rows of child elements 128 * in some kind of grid formation. 129 * @param {number} aX 130 * The X horizontal coordinate that we're finding the closest child node 131 * for. 132 * @param {number} aY 133 * The Y vertical coordinate that we're finding the closest child node 134 * for. 135 * @returns {DOMNode|null} 136 * The closest node to the aX and aY coordinates, preferring child nodes 137 * in the same row of the grid. This may also return the container itself, 138 * if the coordinates are on the outside edge of the last node in the 139 * container. 140 */ 141 find(aContainer, aX, aY) { 142 let closest = null; 143 let minCartesian = Number.MAX_VALUE; 144 let containerX = this.#containerInfo.left; 145 let containerY = this.#containerInfo.top; 146 147 // First, iterate through all children and find the closest child to the 148 // aX and aY coordinates (preferring children in the same row as the aX 149 // and aY coordinates). 150 for (let node of aContainer.children) { 151 let coordinates = this.#lazyStoreGet(node); 152 let offsetX = coordinates.x - containerX; 153 let offsetY = coordinates.y - containerY; 154 let hDiff = offsetX - aX; 155 let vDiff = offsetY - aY; 156 // Then compensate for the height/width ratio so that we prefer items 157 // which are in the same row: 158 hDiff /= this.#heightToWidthFactor; 159 160 let cartesianDiff = hDiff * hDiff + vDiff * vDiff; 161 if (cartesianDiff < minCartesian) { 162 minCartesian = cartesianDiff; 163 closest = node; 164 } 165 } 166 167 // Now refine our result based on whether or not we're closer to the outside 168 // edge of the closest node. If we are, we actually want to return the 169 // closest node's sibling, because this is the one we'll style to indicate 170 // the drop position. 171 if (closest) { 172 let targetBounds = this.#lazyStoreGet(closest); 173 let farSide = this.#rtl ? "left" : "right"; 174 let outsideX = targetBounds[farSide]; 175 // Check if we're closer to the next target than to this one: 176 // Only move if we're not targeting a node in a different row: 177 if (aY > targetBounds.top && aY < targetBounds.bottom) { 178 if ((!this.#rtl && aX > outsideX) || (this.#rtl && aX < outsideX)) { 179 return closest.nextElementSibling || aContainer; 180 } 181 } 182 } 183 return closest; 184 } 185 186 /** 187 * "Insert" a "placeholder" by shifting the subsequent children out of the 188 * way. We go through all the children, and shift them based on the position 189 * they would have if we had inserted something before aBefore. We use CSS 190 * transforms for this, which are CSS transitioned. 191 * 192 * @param {DOMNode} aContainer 193 * The container of the nodes for which we are inserting the placeholder 194 * and shifting the child nodes. 195 * @param {DOMNode} aBefore 196 * The child node before which we are inserting the placeholder. 197 * @param {DOMRectReadOnly} aSize 198 * The size of the placeholder to create. 199 * @param {boolean} aIsFromThisArea 200 * True if the node being dragged happens to be from this container, as 201 * opposed to some other container (like a toolbar, for instance). 202 */ 203 insertPlaceholder(aContainer, aBefore, aSize, aIsFromThisArea) { 204 let isShifted = false; 205 for (let child of aContainer.children) { 206 // Don't need to shift hidden nodes: 207 if (child.hidden) { 208 continue; 209 } 210 // If this is the node before which we're inserting, start shifting 211 // everything that comes after. One exception is inserting at the end 212 // of the menupanel, in which case we do not shift the placeholders: 213 if (child == aBefore) { 214 isShifted = true; 215 } 216 if (isShifted) { 217 if (aIsFromThisArea && !this.#lastPlaceholderInsertion) { 218 child.setAttribute("notransition", "true"); 219 } 220 // Determine the CSS transform based on the next node and apply it. 221 child.style.transform = this.#diffWithNext(child, aSize); 222 } else { 223 // If we're not shifting this node, reset the transform 224 child.style.transform = ""; 225 } 226 } 227 228 // Bug 959848: without this routine, when we start the drag of an item in 229 // the customization palette, we'd take the dragged item out of the flow of 230 // the document, and _then_ insert the placeholder, creating a lot of motion 231 // on the initial drag. We mask this case by removing the item and inserting 232 // the placeholder for the dragged item in a single shot without animation. 233 if ( 234 aContainer.lastElementChild && 235 aIsFromThisArea && 236 !this.#lastPlaceholderInsertion 237 ) { 238 // Flush layout to force the snap transition. 239 aContainer.lastElementChild.getBoundingClientRect(); 240 // then remove all the [notransition] 241 for (let child of aContainer.children) { 242 child.removeAttribute("notransition"); 243 } 244 } 245 this.#lastPlaceholderInsertion = aBefore; 246 } 247 248 /** 249 * Reset all the transforms in this container, optionally without 250 * transitioning them. 251 * 252 * @param {DOMNode} aContainer 253 * The container in which to reset the transforms. 254 * @param {boolean} aNoTransition 255 * If truthy, adds a notransition attribute to the node while resetting the 256 * transform. It is assumed that a CSS rule will interpret the notransition 257 * attribute as a directive to skip transition animations. 258 */ 259 clearPlaceholders(aContainer, aNoTransition) { 260 for (let child of aContainer.children) { 261 if (aNoTransition) { 262 child.setAttribute("notransition", true); 263 } 264 child.style.transform = ""; 265 if (aNoTransition) { 266 // Need to force a reflow otherwise this won't work. :( 267 child.getBoundingClientRect(); 268 child.removeAttribute("notransition"); 269 } 270 } 271 // We snapped back, so we can assume there's no more 272 // "last" placeholder insertion point to keep track of. 273 if (aNoTransition) { 274 this.#lastPlaceholderInsertion = null; 275 } 276 } 277 278 /** 279 * Determines the transform rule to apply to aNode to reposition it to 280 * accommodate a placeholder drop target for a dragged node of aSize. 281 * 282 * @param {DOMNode} aNode 283 * The node to calculate the transform rule for. 284 * @param {DOMRectReadOnly} aSize 285 * The size of the placeholder drop target that was inserted which then 286 * requires us to reposition this node. 287 * @returns {string} 288 * The CSS transform rule to apply to aNode. 289 */ 290 #diffWithNext(aNode, aSize) { 291 let xDiff; 292 let yDiff = null; 293 let nodeBounds = this.#lazyStoreGet(aNode); 294 let side = this.#rtl ? "right" : "left"; 295 let next = this.#getVisibleSiblingForDirection(aNode, "next"); 296 // First we determine the transform along the x axis. 297 // Usually, there will be a next node to base this on: 298 if (next) { 299 let otherBounds = this.#lazyStoreGet(next); 300 xDiff = otherBounds[side] - nodeBounds[side]; 301 // We set this explicitly because otherwise some strange difference 302 // between the height and the actual difference between line creeps in 303 // and messes with alignments 304 yDiff = otherBounds.top - nodeBounds.top; 305 } else { 306 // We don't have a sibling whose position we can use. First, let's see 307 // if we're also the first item (which complicates things): 308 let firstNode = this.#firstInRow(aNode); 309 if (aNode == firstNode) { 310 // Maybe we stored the horizontal distance between nodes, 311 // if not, we'll use the width of the incoming node as a proxy: 312 xDiff = this.#horizontalDistance || (this.#rtl ? -1 : 1) * aSize.width; 313 } else { 314 // If not, we should be able to get the distance to the previous node 315 // and use the inverse, unless there's no room for another node (ie we 316 // are the last node and there's no room for another one) 317 xDiff = this.#moveNextBasedOnPrevious(aNode, nodeBounds, firstNode); 318 } 319 } 320 321 // If we've not determined the vertical difference yet, check it here 322 if (yDiff === null) { 323 // If the next node is behind rather than in front, we must have moved 324 // vertically: 325 if ((xDiff > 0 && this.#rtl) || (xDiff < 0 && !this.#rtl)) { 326 yDiff = aSize.height; 327 } else { 328 // Otherwise, we haven't 329 yDiff = 0; 330 } 331 } 332 return "translate(" + xDiff + "px, " + yDiff + "px)"; 333 } 334 335 /** 336 * Helper function to find the horizontal transform value for a node if there 337 * isn't a next node to base that on. 338 * 339 * @param {DOMNode} aNode 340 * The node to have the transform applied to. 341 * @param {DOMRectReadOnly} aNodeBounds 342 * The bounding rect info of aNode. 343 * @param {DOMNode} aFirstNodeInRow 344 * The first node in aNode's row in the container grid. 345 * @returns {number} 346 * The horizontal distance to transform aNode. 347 */ 348 #moveNextBasedOnPrevious(aNode, aNodeBounds, aFirstNodeInRow) { 349 let next = this.#getVisibleSiblingForDirection(aNode, "previous"); 350 let otherBounds = this.#lazyStoreGet(next); 351 let side = this.#rtl ? "right" : "left"; 352 let xDiff = aNodeBounds[side] - otherBounds[side]; 353 // If, however, this means we move outside the container's box 354 // (i.e. the row in which this item is placed is full) 355 // we should move it to align with the first item in the next row instead 356 let bound = this.#containerInfo[this.#rtl ? "left" : "right"]; 357 if ( 358 (!this.#rtl && xDiff + aNodeBounds.right > bound) || 359 (this.#rtl && xDiff + aNodeBounds.left < bound) 360 ) { 361 xDiff = this.#lazyStoreGet(aFirstNodeInRow)[side] - aNodeBounds[side]; 362 } 363 return xDiff; 364 } 365 366 /** 367 * Get the DOMRectReadOnly for a node from our cache. If the rect is not yet 368 * cached, calculate that rect and cache it now. 369 * 370 * @param {DOMNode} aNode 371 * The node whose DOMRectReadOnly that we want. 372 * @returns {DOMRectReadOnly} 373 * The size and position of aNode that was either just calculated, or 374 * previously calculated during the lifetime of this AreaPositionManager and 375 * cached. 376 */ 377 #lazyStoreGet(aNode) { 378 let rect = this.#nodePositionStore.get(aNode); 379 if (!rect) { 380 // getBoundingClientRect() returns a DOMRect that is live, meaning that 381 // as the element moves around, the rects values change. We don't want 382 // that - we want a snapshot of what the rect values are right at this 383 // moment, and nothing else. So we have to clone the values as a 384 // DOMRectReadOnly. 385 rect = DOMRectReadOnly.fromRect(aNode.getBoundingClientRect()); 386 this.#nodePositionStore.set(aNode, rect); 387 } 388 return rect; 389 } 390 391 /** 392 * Returns the first node in aNode's row in the container grid. 393 * 394 * @param {DOMNode} aNode 395 * The node in the row for which we want to find the first node. 396 * @returns {DOMNode} 397 */ 398 #firstInRow(aNode) { 399 // XXXmconley: I'm not entirely sure why we need to take the floor of these 400 // values - it looks like, periodically, we're getting fractional pixels back 401 // from lazyStoreGet. I've filed bug 994247 to investigate. 402 let bound = Math.floor(this.#lazyStoreGet(aNode).top); 403 let rv = aNode; 404 let prev; 405 while (rv && (prev = this.#getVisibleSiblingForDirection(rv, "previous"))) { 406 if (Math.floor(this.#lazyStoreGet(prev).bottom) <= bound) { 407 return rv; 408 } 409 rv = prev; 410 } 411 return rv; 412 } 413 414 /** 415 * Returns the next visible sibling DOMNode to aNode in the direction 416 * aDirection. 417 * 418 * @param {DOMNode} aNode 419 * The node to get the next visible sibling for. 420 * @param {string} aDirection 421 * One of either "previous" or "next". Any other value will probably throw. 422 * @returns {DOMNode} 423 */ 424 #getVisibleSiblingForDirection(aNode, aDirection) { 425 let rv = aNode; 426 do { 427 rv = rv[aDirection + "ElementSibling"]; 428 } while (rv && rv.hidden); 429 return rv; 430 } 431 } 432 433 /** 434 * DragPositionManager manages the AreaPositionManagers for all of the 435 * grid-like customizable areas. These days, that's just the customization 436 * palette. 437 */ 438 export var DragPositionManager = { 439 /** 440 * Starts CustomizeMode drag position management for a window aWindow. 441 * 442 * @param {DOMWindow} aWindow 443 * The browser window to start drag position management in. 444 */ 445 start(aWindow) { 446 let paletteArea = aWindow.document.getElementById(kPaletteId); 447 let positionManager = gManagers.get(paletteArea); 448 if (positionManager) { 449 positionManager.update(paletteArea); 450 } else { 451 // This gManagers WeakMap may have made more sense when we had the 452 // menu panel also acting as a grid. It's maybe superfluous at this point. 453 gManagers.set(paletteArea, new AreaPositionManager(paletteArea)); 454 } 455 }, 456 457 /** 458 * Stops CustomizeMode drag position management for all windows. 459 */ 460 stop() { 461 gManagers = new WeakMap(); 462 }, 463 464 /** 465 * Returns the AreaPositionManager instance for a particular aArea DOMNode, 466 * if one has been created. 467 * 468 * @param {DOMNode} aArea 469 * @returns {AreaPositionManager|null} 470 */ 471 getManagerForArea(aArea) { 472 return gManagers.get(aArea); 473 }, 474 }; 475 476 Object.freeze(DragPositionManager);