tor-browser

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

LazyMessageList.js (13547B)


      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 * This file incorporates work covered by the following copyright and
      6 * permission notice:
      7 *
      8 *   MIT License
      9 *
     10 *   Copyright (c) 2019 Oleg Grishechkin
     11 *
     12 *   Permission is hereby granted, free of charge, to any person obtaining a copy
     13 *   of this software and associated documentation files (the "Software"), to deal
     14 *   in the Software without restriction, including without limitation the rights
     15 *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     16 *   copies of the Software, and to permit persons to whom the Software is
     17 *   furnished to do so, subject to the following conditions:
     18 *
     19 *   The above copyright notice and this permission notice shall be included in all
     20 *   copies or substantial portions of the Software.
     21 *
     22 *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     23 *   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
     24 *   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
     25 *   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
     26 *   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
     27 *   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
     28 *   SOFTWARE.
     29 */
     30 "use strict";
     31 
     32 const {
     33  Fragment,
     34  Component,
     35  createElement,
     36  createRef,
     37 } = require("resource://devtools/client/shared/vendor/react.mjs");
     38 
     39 loader.lazyRequireGetter(
     40  this,
     41  "PropTypes",
     42  "resource://devtools/client/shared/vendor/react-prop-types.js"
     43 );
     44 
     45 // This element is a webconsole optimization for handling large numbers of
     46 // console messages. The purpose is to only create DOM elements for messages
     47 // which are actually visible within the scrollport. This code was based on
     48 // Oleg Grishechkin's react-viewport-list element - however, it has been quite
     49 // heavily modified, to the point that it is mostly unrecognizable. The most
     50 // notable behavioral modification is that the list implements the behavior of
     51 // pinning the scrollport to the bottom of the scroll container.
     52 class LazyMessageList extends Component {
     53  static get propTypes() {
     54    return {
     55      viewportRef: PropTypes.shape({
     56        // Note that we can't use Element here because, the Element global is
     57        // exposed from base-loader and is not the same as window.Element.
     58        // Also PropTypes.instanceOf relies solely on `instanceof` and not on
     59        // isInstance, so we really need to use the actual constructor.
     60        current: PropTypes.instanceOf(window.Element),
     61      }).isRequired,
     62      items: PropTypes.array.isRequired,
     63      itemsToKeepAlive: PropTypes.shape({
     64        has: PropTypes.func,
     65        keys: PropTypes.func,
     66        size: PropTypes.number,
     67      }).isRequired,
     68      editorMode: PropTypes.bool.isRequired,
     69      itemDefaultHeight: PropTypes.number.isRequired,
     70      scrollOverdrawCount: PropTypes.number.isRequired,
     71      renderItem: PropTypes.func.isRequired,
     72      shouldScrollBottom: PropTypes.func.isRequired,
     73      cacheGeneration: PropTypes.number.isRequired,
     74      serviceContainer: PropTypes.shape({
     75        emitForTests: PropTypes.func.isRequired,
     76      }),
     77    };
     78  }
     79 
     80  constructor(props) {
     81    super(props);
     82    this.#initialized = false;
     83    this.#topBufferRef = createRef();
     84    this.#bottomBufferRef = createRef();
     85    this.#viewportHeight = window.innerHeight;
     86    this.#startIndex = 0;
     87    this.#resizeObserver = null;
     88    this.#cachedHeights = [];
     89 
     90    this.#scrollHandlerBinding = this.#scrollHandler.bind(this);
     91  }
     92 
     93  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
     94  UNSAFE_componentWillUpdate(nextProps) {
     95    if (nextProps.cacheGeneration !== this.props.cacheGeneration) {
     96      this.#cachedHeights = [];
     97      this.#startIndex = 0;
     98    } else if (
     99      (this.props.shouldScrollBottom() &&
    100        nextProps.items.length > this.props.items.length) ||
    101      this.#startIndex > nextProps.items.length - this.#numItemsToDraw
    102    ) {
    103      this.#startIndex = Math.max(
    104        0,
    105        nextProps.items.length - this.#numItemsToDraw
    106      );
    107    }
    108  }
    109 
    110  componentDidUpdate(prevProps) {
    111    const { viewportRef, serviceContainer } = this.props;
    112    if (!viewportRef.current || !this.#topBufferRef.current) {
    113      return;
    114    }
    115 
    116    if (!this.#initialized) {
    117      // We set these up from a one-time call in componentDidUpdate, rather than in
    118      // componentDidMount, because we need the parent to be mounted first, to add
    119      // listeners to it, and React orders things such that children mount before
    120      // parents.
    121      this.#addListeners();
    122    }
    123 
    124    if (!this.#initialized || prevProps.editorMode !== this.props.editorMode) {
    125      this.#resizeObserver.observe(viewportRef.current);
    126    }
    127 
    128    this.#initialized = true;
    129 
    130    // Since we updated, we're now going to compute the heights of all visible
    131    // elements and store them in a cache. This allows us to get more accurate
    132    // buffer regions to make scrolling correct when these elements no longer
    133    // exist.
    134    let index = this.#startIndex;
    135    let element = this.#topBufferRef.current.nextSibling;
    136    let elementRect = element?.getBoundingClientRect();
    137    while (
    138      Element.isInstance(element) &&
    139      index < this.#clampedEndIndex &&
    140      element !== this.#bottomBufferRef.current
    141    ) {
    142      const next = element.nextSibling;
    143      const nextRect = next.getBoundingClientRect();
    144      this.#cachedHeights[index] = nextRect.top - elementRect.top;
    145      element = next;
    146      elementRect = nextRect;
    147      index++;
    148    }
    149 
    150    serviceContainer.emitForTests("lazy-message-list-updated-or-noop");
    151  }
    152 
    153  componentWillUnmount() {
    154    this.#removeListeners();
    155  }
    156 
    157  #initialized;
    158  #topBufferRef;
    159  #bottomBufferRef;
    160  #viewportHeight;
    161  #startIndex;
    162  #resizeObserver;
    163  #cachedHeights;
    164  #scrollHandlerBinding;
    165 
    166  get #overdrawHeight() {
    167    return this.props.scrollOverdrawCount * this.props.itemDefaultHeight;
    168  }
    169 
    170  get #numItemsToDraw() {
    171    const scrollingWindowCount = Math.ceil(
    172      this.#viewportHeight / this.props.itemDefaultHeight
    173    );
    174    return scrollingWindowCount + 2 * this.props.scrollOverdrawCount;
    175  }
    176 
    177  get #unclampedEndIndex() {
    178    return this.#startIndex + this.#numItemsToDraw;
    179  }
    180 
    181  // Since the "end index" is computed based off a fixed offset from the start
    182  // index, it can exceed the length of our items array. This is just a helper
    183  // to ensure we don't exceed that.
    184  get #clampedEndIndex() {
    185    return Math.min(this.#unclampedEndIndex, this.props.items.length);
    186  }
    187 
    188  /**
    189   * Increases our start index until we've passed enough elements to cover
    190   * the difference in px between where we are and where we want to be.
    191   *
    192   * @param Number startIndex
    193   *        The current value of our start index.
    194   * @param Number deltaPx
    195   *        The difference in pixels between where we want to be and
    196   *        where we are.
    197   * @return {number} The new computed start index.
    198   */
    199  #increaseStartIndex(startIndex, deltaPx) {
    200    for (let i = startIndex + 1; i < this.props.items.length; i++) {
    201      deltaPx -= this.#cachedHeights[i];
    202      startIndex = i;
    203 
    204      if (deltaPx <= 0) {
    205        break;
    206      }
    207    }
    208    return startIndex;
    209  }
    210 
    211  /**
    212   * Decreases our start index until we've passed enough elements to cover
    213   * the difference in px between where we are and where we want to be.
    214   *
    215   * @param Number startIndex
    216   *        The current value of our start index.
    217   * @param Number deltaPx
    218   *        The difference in pixels between where we want to be and
    219   *        where we are.
    220   * @return {number} The new computed start index.
    221   */
    222  #decreaseStartIndex(startIndex, diff) {
    223    for (let i = startIndex - 1; i >= 0; i--) {
    224      diff -= this.#cachedHeights[i];
    225      startIndex = i;
    226 
    227      if (diff <= 0) {
    228        break;
    229      }
    230    }
    231    return startIndex;
    232  }
    233 
    234  #scrollHandler() {
    235    if (!this.props.viewportRef.current || !this.#topBufferRef.current) {
    236      return;
    237    }
    238 
    239    const scrollportMin =
    240      this.props.viewportRef.current.getBoundingClientRect().top -
    241      this.#overdrawHeight;
    242    const uppermostItemRect =
    243      this.#topBufferRef.current.nextSibling.getBoundingClientRect();
    244    const uppermostItemMin = uppermostItemRect.top;
    245    const uppermostItemMax = uppermostItemRect.bottom;
    246 
    247    let nextStartIndex = this.#startIndex;
    248    const downwardPx = scrollportMin - uppermostItemMax;
    249    const upwardPx = uppermostItemMin - scrollportMin;
    250    if (downwardPx > 0) {
    251      nextStartIndex = this.#increaseStartIndex(nextStartIndex, downwardPx);
    252    } else if (upwardPx > 0) {
    253      nextStartIndex = this.#decreaseStartIndex(nextStartIndex, upwardPx);
    254    }
    255 
    256    nextStartIndex = Math.max(
    257      0,
    258      Math.min(nextStartIndex, this.props.items.length - this.#numItemsToDraw)
    259    );
    260 
    261    if (nextStartIndex !== this.#startIndex) {
    262      this.#startIndex = nextStartIndex;
    263      this.forceUpdate();
    264    } else {
    265      const { serviceContainer } = this.props;
    266      serviceContainer.emitForTests("lazy-message-list-updated-or-noop");
    267    }
    268  }
    269 
    270  #addListeners() {
    271    const { viewportRef } = this.props;
    272    viewportRef.current.addEventListener("scroll", this.#scrollHandlerBinding);
    273    this.#resizeObserver = new ResizeObserver(() => {
    274      this.#viewportHeight =
    275        viewportRef.current.parentNode.parentNode.clientHeight;
    276      this.forceUpdate();
    277    });
    278  }
    279 
    280  #removeListeners() {
    281    const { viewportRef } = this.props;
    282    this.#resizeObserver?.disconnect();
    283    viewportRef.current?.removeEventListener(
    284      "scroll",
    285      this.#scrollHandlerBinding
    286    );
    287  }
    288 
    289  get bottomBuffer() {
    290    return this.#bottomBufferRef.current;
    291  }
    292 
    293  isItemNearBottom(index) {
    294    return index >= this.props.items.length - this.#numItemsToDraw;
    295  }
    296 
    297  render() {
    298    const { items, itemDefaultHeight, renderItem, itemsToKeepAlive } =
    299      this.props;
    300    if (!items.length) {
    301      return createElement(Fragment, {
    302        key: "LazyMessageList",
    303      });
    304    }
    305 
    306    // Resize our cached heights to fit if necessary.
    307    const countUncached = items.length - this.#cachedHeights.length;
    308    if (countUncached > 0) {
    309      // It would be lovely if javascript allowed us to resize an array in one
    310      // go. I think this is the closest we can get to that. This in theory
    311      // allows us to realloc, and doesn't require copying the whole original
    312      // array like concat does.
    313      this.#cachedHeights.push(...Array(countUncached).fill(itemDefaultHeight));
    314    }
    315 
    316    let topBufferHeight = 0;
    317    let bottomBufferHeight = 0;
    318    // We can't compute the bottom buffer height until the end, so we just
    319    // store the index of where it needs to go.
    320    let bottomBufferIndex = 0;
    321    let currentChild = 0;
    322    const startIndex = this.#startIndex;
    323    const endIndex = this.#clampedEndIndex;
    324    // We preallocate this array to avoid allocations in the loop. The minimum,
    325    // and typical length for it is the size of the body plus 2 for the top and
    326    // bottom buffers. It can be bigger due to itemsToKeepAlive, but we can't just
    327    // add the size, since itemsToKeepAlive could in theory hold items which are
    328    // not even in the list.
    329    const children = new Array(endIndex - startIndex + 2);
    330    const pushChild = c => {
    331      if (currentChild >= children.length) {
    332        children.push(c);
    333      } else {
    334        children[currentChild] = c;
    335      }
    336      return currentChild++;
    337    };
    338    for (let i = 0; i < items.length; i++) {
    339      const itemId = items[i];
    340      if (i < startIndex) {
    341        if (i == 0 || itemsToKeepAlive.has(itemId)) {
    342          // If this is our first item, and we wouldn't otherwise be rendering
    343          // it, we want to ensure that it's at the beginning of our children
    344          // array to ensure keyboard navigation functions properly.
    345          pushChild(renderItem(itemId, i));
    346        } else {
    347          topBufferHeight += this.#cachedHeights[i];
    348        }
    349      } else if (i < endIndex) {
    350        if (i == startIndex) {
    351          pushChild(
    352            createElement("div", {
    353              key: "LazyMessageListTop",
    354              className: "lazy-message-list-top",
    355              ref: this.#topBufferRef,
    356              style: { height: topBufferHeight },
    357            })
    358          );
    359        }
    360        pushChild(renderItem(itemId, i));
    361        if (i == endIndex - 1) {
    362          // We're just reserving the bottom buffer's spot in the children
    363          // array here. We will create the actual element and assign it at
    364          // this index after the loop.
    365          bottomBufferIndex = pushChild(null);
    366        }
    367      } else if (i == items.length - 1 || itemsToKeepAlive.has(itemId)) {
    368        // Similarly to the logic for our first item, we also want to ensure
    369        // that our last item is always rendered as the last item in our
    370        // children array.
    371        pushChild(renderItem(itemId, i));
    372      } else {
    373        bottomBufferHeight += this.#cachedHeights[i];
    374      }
    375    }
    376 
    377    children[bottomBufferIndex] = createElement("div", {
    378      key: "LazyMessageListBottom",
    379      className: "lazy-message-list-bottom",
    380      ref: this.#bottomBufferRef,
    381      style: { height: bottomBufferHeight },
    382    });
    383 
    384    return createElement(
    385      Fragment,
    386      {
    387        key: "LazyMessageList",
    388      },
    389      children
    390    );
    391  }
    392 }
    393 
    394 module.exports = LazyMessageList;