src/sectionRegistry.js

/**
 * Singleton storing data about sections on the page and managing them.
 *
 * @module sectionRegistry
 */

import cd from './cd';
import controller from './controller';
import settings from './settings';
import { areObjectsEqual, calculateWordOverlap, generateFixedPosTimestamp, spacesToUnderlines } from './utils-general';
import { getExtendedRect, getVisibilityByRects } from './utils-window';
import visits from './visits';

// TODO: make into a class extending a generic registry.

export default {
  /**
   * List of sections.
   *
   * @type {import('./Section').default[]}
   * @private
   */
  items: [],

  /**
   * _For internal use._ Initialize the registry.
   *
   * @param {import('./Subscriptions').default} subscriptions
   */
  init(subscriptions) {
    this.improvePerformance = settings.get('improvePerformance');

    controller
      .on('scroll', this.maybeUpdateVisibility.bind(this));
    subscriptions
      .on('process', this.addSubscribeButtons.bind(this));
    visits
      .on('process', this.updateNewCommentsData.bind(this));

    if (this.improvePerformance) {
      // Unhide when the user opens a search box to allow searching the full page.
      $(window)
        .on('focus', this.maybeUpdateVisibility.bind(this))
        .on('blur', this.maybeUnhideAll.bind(this));
    }
  },

  /**
   * _For internal use._ Perform some section-related operations when the registry is filled, in
   * addition to those performed when each section is added to the registry. Set the
   * {@link Section#isLastSection isLastSection} property, adding buttons, and binding events.
   */
  setup() {
    this.items.forEach((section) => {
      /**
       * Is the section the last section on the page.
       *
       * @name isLastSection
       * @type {boolean}
       * @memberof Section
       * @instance
       */
      section.isLastSection = section.index === this.items.length - 1;

      section.maybeAddAddSubsectionButton();
      section.maybeAddReplyButton();
    });

    // Run this after running section.addReplyButton() for each section because reply buttons must
    // be in place for this.
    this.items
      .filter((section) => section.addSubsectionButton)
      .forEach((section) => {
        // Section with the last reply button
        (section.getChildren(true).slice(-1)[0] || section)
          .showAddSubsectionButtonOnReplyButtonHover(section);
      });
  },

  /**
   * Add a section to the list.
   *
   * @param {import('./Section').default} item
   */
  add(item) {
    this.items.push(item);
  },

  /**
   * Get all sections on the page ordered the same way as in the DOM.
   *
   * @returns {import('./Section').default[]}
   */
  getAll() {
    return this.items;
  },

  /**
   * Get a section by index.
   *
   * @param {number} index Use a negative index to count from the end.
   * @returns {?import('./Section').default}
   */
  getByIndex(index) {
    if (index < 0) {
      index = this.items.length + index;
    }
    return this.items[index] || null;
  },

  /**
   * Get the number of sections.
   *
   * @returns {number}
   */
  getCount() {
    return this.items.length;
  },

  /**
   * Get sections by a condition.
   *
   * @param {Function} condition
   * @returns {import('./Section').default[]}
   */
  query(condition) {
    return this.items.filter(condition);
  },

  /**
   * Reset the section list.
   */
  reset() {
    this.items = [];
  },

  /**
   * Get a section by ID.
   *
   * @param {string} id
   * @returns {?import('./Section').default}
   */
  getById(id) {
    return id && this.items.find((section) => section.id === id) || null;
  },

  /**
   * Get sections by headline.
   *
   * @param {string} headline
   * @returns {import('./Section').default[]}
   */
  getByHeadline(headline) {
    return this.items.filter((section) => section.headline === headline);
  },

  /**
   * Get sections by {@link Section#subscribeId subscribe ID}.
   *
   * @param {string} subscribeId
   * @returns {import('./Section').default[]}
   */
  getBySubscribeId(subscribeId) {
    return this.items.filter((section) => section.subscribeId === subscribeId);
  },

  /**
   * Find a section with a similar name on the page (when the section with the exact name was not
   * found).
   *
   * @param {string} sectionName
   * @returns {?import('./Section').default}
   */
  findByHeadlineParts(sectionName) {
    return (
      this.items
        .map((section) => ({
          section,
          score: calculateWordOverlap(sectionName, section.headline),
        }))
        .filter((match) => match.score > 0.66)
        .sort((m1, m2) => m2.score - m1.score)[0]
        ?.section ||
      null
    );
  },

  /**
   * Search for a section on the page based on several parameters: index, headline, id, ancestor
   * sections' headlines, oldest comment data. At least two parameters must match, not counting
   * index and id. The section that matches best is returned.
   *
   * @param {object} options
   * @param {number} options.index
   * @param {string} options.headline
   * @param {string} options.id
   * @param {string[]} options.ancestors
   * @param {string} options.oldestCommentId
   * @returns {?import('./Section').default}
   */
  search({ index, headline, id, ancestors, oldestCommentId }) {
    const matches = [];
    this.items.some((section) => {
      const doesIndexMatch = section.index === index;
      const doesHeadlineMatch = section.headline === headline;
      const doesIdMatch = section.id === id;
      const doAncestorsMatch = ancestors ?
        areObjectsEqual(section.getAncestors().map((section) => section.headline), ancestors) :
        false;
      const doesOldestCommentMatch = section.oldestComment?.id === oldestCommentId;
      const score = (
        doesHeadlineMatch * 1 +
        doAncestorsMatch * 1 +
        doesOldestCommentMatch * 1 +
        doesIdMatch * 0.5 +
        doesIndexMatch * 0.001
      );
      if (score >= 2) {
        matches.push({ section, score });
      }

      // 3.5 score means it's the best match for sure. Two sections can't have coinciding IDs, so
      // there can't be two sections with the 3.5 score. (We do this because there can be very many
      // sections on the page, so searching for a match for every section, e.g. in updateChecker.js,
      // can be expensive.)
      return score >= 3.5;
    });

    let bestMatch;
    matches.forEach((match) => {
      if (!bestMatch || match.score > bestMatch.score) {
        bestMatch = match;
      }
    });
    return bestMatch || null;
  },

  /**
   * Add a "Subscribe" / "Unsubscribe" button to each section's actions element.
   *
   * @private
   */
  addSubscribeButtons() {
    if (!cd.user.isRegistered()) return;

    controller.saveRelativeScrollPosition();
    this.items.forEach((section) => {
      section.addSubscribeButton();
    });
    controller.restoreRelativeScrollPosition();
  },

  /**
   * Generate an DiscussionTools ID for a section.
   *
   * @param {string} author Author name.
   * @param {Date} timestamp Oldest comment date.
   * @returns {string}
   */
  generateDtSubscriptionId(author, timestamp) {
    const date = new Date(timestamp);
    date.setSeconds(0);
    return `h-${spacesToUnderlines(author)}-${generateFixedPosTimestamp(date, '00')}`;
  },

  /**
   * _For internal use._ Add the metadata and actions elements below or to the right of each section
   * heading.
   */
  addMetadataAndActions() {
    this.items.forEach((section) => {
      section.addMetadataAndActions();
    });
  },

  /**
   * _For internal use._ Update the new comments data for sections and render the updates.
   */
  updateNewCommentsData() {
    this.items.forEach((section) => {
      section.updateNewCommentsData();
    });
  },

  /**
   * _For internal use._ Get the top offset of the first section relative to the viewport.
   *
   * @param {number} [scrollY=window.scrollY]
   * @param {number} [tocOffset]
   * @returns {?number}
   */
  getFirstSectionRelativeTopOffset(scrollY = window.scrollY, tocOffset) {
    if (scrollY <= cd.g.bodyScrollPaddingTop) {
      return null;
    }

    let top;
    this.items.some((section) => {
      const rect = getExtendedRect(section.headingElement);

      // The third check to exclude the possibility that the first section is above the TOC, like
      // at https://commons.wikimedia.org/wiki/Project:Graphic_Lab/Illustration_workshop.
      if (getVisibilityByRects(rect) && (!tocOffset || rect.outerTop > tocOffset)) {
        top = rect.outerTop;
      }

      return top !== undefined;
    });

    return top;
  },

  /**
   * Get the section currently positioned at the top of the viewport.
   *
   * @returns {?import('./Section').default}
   */
  getCurrentSection() {
    const firstSectionTop = this.getFirstSectionRelativeTopOffset();
    return (
      firstSectionTop !== null &&
      firstSectionTop < cd.g.bodyScrollPaddingTop + 1 &&
      this.items
        .slice()
        .reverse()
        .find((section) => {
          const extendedRect = getExtendedRect(section.headingElement);
          return (
            getVisibilityByRects(extendedRect) &&
            extendedRect.outerTop < cd.g.bodyScrollPaddingTop + 1
          );
        }) ||
      null
    );
  },

  /**
   * Make sections visible or invisible to improve performance if the corresponding setting is
   * enabled.
   *
   * @private
   */
  maybeUpdateVisibility() {
    if (
      !this.improvePerformance ||
      !this.items.length ||
      !controller.isLongPage() ||

      // When the document has no focus, all sections are visible (see .maybeUnhideAll()).
      !document.hasFocus()
    ) {
      return;
    }

    // Don't care about top scroll padding (the sticky header's height) here.
    const viewportTop = window.scrollY;

    const pageHeight = document.documentElement.scrollHeight;
    const threeScreens = window.innerHeight * 3;

    let firstSectionToHide;
    if (pageHeight - viewportTop > 20000) {
      const currentSection = this.getCurrentSection();
      firstSectionToHide = this.items
        .filter((section) => !currentSection || section.index > currentSection.index)
        .find((section) => {
          const rect = section.headingElement.getBoundingClientRect();
          let blockSize = 10000;
          return (
            getVisibilityByRects(rect) &&
            rect.top >= threeScreens &&

            // Is in a different `blockSize`-pixel block than the viewport top. (threeScreens is
            // subtracted from its position to reduce the frequency of CSS manipulations, so in
            // practice the blocks are positioned somewhat like this: 0 - 12500, 12500 - 22500,
            // 22500 - 32500, etc.)
            (
              Math.floor(viewportTop / blockSize) !==
              Math.floor((viewportTop + rect.top - threeScreens) / blockSize)
            )
          );
        });
    }

    const subsectionsToHide = [];
    if (firstSectionToHide) {
      this.items
        .slice(firstSectionToHide.index)
        .some((section) => {
          if (section.level === 2) {
            return true;
          }
          subsectionsToHide.push(section);
          return false;
        });
    }
    this.items
      .filter((section) => (
        section.level === 2 ||
        section.isHidden ||
        subsectionsToHide.includes(section)
      ))
      .forEach((section) => {
        section.updateVisibility(
          !(firstSectionToHide && section.index >= firstSectionToHide.index)
        );
      });
  },

  /**
   * _For internal use._ Unhide the sections.
   *
   * This is called when the "Try to improve performance" setting is enabled and the window is
   * blurred.
   */
  maybeUnhideAll() {
    if (!controller.isLongPage()) return;

    this.items.forEach((section) => {
      section.updateVisibility(true);
    });
  },
};