tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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);