Source: src/js/views/TOCView.js

define([
  "jquery",
  "underscore",
  "backbone",
  "text!templates/tableOfContentsLi.html",
  "text!templates/tableOfContentsUL.html",
  "text!templates/tableOfContents.html",
], function ($, _, Backbone, TOCTemplateLi, TOCTemplateUl, TOCTemplate) {
  /**
     * @class TOCView
     * @classdesc    The Table of Contents View is a vertical navigation menu that links to other
        sections within the same view.

        The TOC can have 2 levels of content. The top level is referred to as 'topLevelItem'.
        Second level items are referred to as 'h2' (they come from 'h2' tags). H1s get passed
        in when the TOC view is instantiated (see `PortalSectionView.js` for an example). If
        there are 'h2' tags within the 'topLevelItem' containers, these will be listed under
        the 'topLevelItem'.
     * @classcategory Views
     * @extends Backbone.View
    */
  var TOCView = Backbone.View.extend(
    /** @lends TOCView.prototype */ {
      tagName: "div",
      className: "toc toc-view",

      type: "TOC",

      /* Renders the compiled template into HTML */
      templateUL: _.template(TOCTemplateUl),
      templateLI: _.template(TOCTemplateLi),
      mainTemplate: _.template(TOCTemplate),
      templateInvisibleH1: _.template(
        '<h1 id="<%=linkDisplay%>" style="display: inline"></h1>',
      ),

      events: {
        "click .dropdown": "toggleDropdown",
      },

      // The element on the page that contains the content that this table of contents
      //  is associated with.
      contentEl: null,

      /*
        * A list of custom items to insert into the TOC
        * {
            "text": "Portal Description",
            "icon": "icon-file-text-alt",
            "link": $(".header"),
            "showSubItems": false
          }
        */
      topLevelItems: {},

      //If set to true, will render one level of sub items/links
      showSubItems: true,

      /* Construct a new instance  */
      initialize: function (options) {
        if (typeof options !== "undefined") {
          this.topLevelItems = options.topLevelItems || "";
          this.contentEl = options.contentEl || "";
          this.className = options.className || "toc toc-view";
          this.addScrollspy = options.addScrollspy || true;
          this.affix = options.affix || true;

          if (options.showSubItems === false) {
            this.showSubItems = false;
          }
        }
      },

      /* Render the view */
      render: function () {
        var liTemplate = this.templateLI,
          h1Template = this.templateInvisibleH1;

        this.$el.html(
          this.mainTemplate({
            ulTemplate: this.templateUL(),
          }),
        );

        // Save references to where we should insert the links
        this.desktopUl = this.$el.find(".desktop ul");
        this.mobile = this.$el.find(".mobile");
        // Save references to the toggle links (and divider) so we can update their text/display on spyScoll
        this.topLevelMobileToggle = this.mobile.find(
          ".top-level-items .dropdown-toggle",
        );
        this.secondLevelMobileToggle = this.mobile.find(
          ".second-level-items .dropdown-toggle",
        );
        this.mobileDivider = this.mobile.find(".mobile-toc-divider");

        // Render the top level items that have been passed in
        _.each(
          this.topLevelItems,
          function (topLevelItem) {
            // Create a link to display based on the text of the TOC item
            topLevelItem.linkDisplay = topLevelItem.text
              .replace(/[\W_]+/g, "-")
              .toLowerCase()
              .replace(/^[\W_]+/g, "");

            // Make an invisible (empty) H1 tag and stick it into the el
            // that's the target of the TOC
            $(topLevelItem.link).prepend(
              h1Template({ linkDisplay: topLevelItem.linkDisplay }),
            );

            // Render the top level item
            this.desktopUl.append(liTemplate(topLevelItem));

            if (
              typeof topLevelItem.showSubItems == "undefined" ||
              topLevelItem.showSubItems == true
            ) {
              _.each(
                this.createLinksFromHeaders(topLevelItem.link),
                function (link, index) {
                  this.appendLink(link, index);
                },
                this,
              );
            }
          },
          this,
        );

        // If no custom top-level items were given, find the headers in the content
        if (!this.topLevelItems.length && this.contentEl) {
          //Create links from the headers found in the content
          _.each(
            this.createLinksFromHeaders(),
            function (link, index) {
              this.appendLink(link, index);
            },
            this,
          );
        }

        return this;
      },

      /**
       * appendLink - Adds the generated link to both the desktop and mobile TOCs
       *
       * @param  {HTMLLIElement} link  The top-level item to add, including second-level UL if present
       * @param  {number} index The index of the top-level item
       */
      appendLink: function (link, index) {
        // Append to the main (desktop) navigation
        this.desktopUl.append(link);

        // Append to the mobile navigation
        var mobileLink = $(link.clone()),
          submenu = $(mobileLink.find("ul").clone());

        // Make a list of only top-level li's
        mobileLink.find("ul").remove();
        mobileLink.data("index", index);
        this.mobile.find(".top-level-items ul").append(mobileLink);
        // If there's a submenu, add it to the second-level-items menu.
        // Add ID that allows us to match the parent to the submenu.
        if (submenu && submenu.length) {
          submenu.addClass("submenu hidden");
          submenu.data("index", index);
          submenu.children("li").data("index", index);
          this.mobile
            .find(".second-level-items .dropdown-menu")
            .append(submenu);
        }
      },

      createLinksFromHeaders: function (contentEl, headerLevel) {
        //If no content element is specified, use the one attached to this view
        if (!contentEl && this.contentEl) {
          var contentEl = this.contentEl;
        }
        //If there is no content element attached to the view either, then exit
        else if (!contentEl && !this.contentEl) {
          return [];
        }

        //If there is no header level specified, find them in the content
        if (!headerLevel) {
          var headerLevel;

          //Use the first-level header if it exists
          if ($(contentEl).find("h1").length) {
            headerLevel = "h1";
          }
          //Otherwise find second-level headers
          else if ($(contentEl).find("h2").length) {
            headerLevel = "h2";
          }
          //Otherwise find third-level headers
          else if ($(contentEl).find("h3").length) {
            headerLevel = "h3";
          }
          //Exit this function if there are no headers
          else {
            return [];
          }
        }

        //Create an array to contain all the link elements
        var linkItems = [];

        // Within each top level item, look for header tags and
        // render them as second level TOC items
        var headers = $(contentEl).find(headerLevel);

        _.each(
          headers,
          function (header) {
            //Create the link HTML
            var linkItem = $(
              this.templateLI({
                link: "#" + $(header).attr("id"),
                text: $(header).text(),
                LIclass: "top-level-item",
              }),
            );

            linkItem.addClass("top-level-item");

            //If we want to show subitems in the table of contents, find them
            if (this.showSubItems) {
              var nextEl = $(header).next(),
                subHeaderLevel = "H" + (parseInt(headerLevel.charAt(1)) + 1),
                subItems = $(this.templateUL());

              while (nextEl.length) {
                if (nextEl[0].tagName == subHeaderLevel) {
                  subItems.append(
                    this.templateLI({
                      link: "#" + nextEl.attr("id"),
                      text: nextEl.text(),
                      LIclass: "second-level-item",
                    }),
                  );
                } else if (nextEl[0].tagName == headerLevel.toUpperCase()) {
                  break;
                }

                nextEl = nextEl.next();
              }

              //If at least one subheader/subitem was found, add them to the top-level item
              if (subItems.children().length) {
                linkItem.append(subItems);
              }
            }

            //Create the link item and add to the array
            linkItems.push(linkItem);
          },
          this,
        );

        return linkItems;
      },

      /**
       * addScrollspy - Adds and refreshes bootstrap's scrollSpy functionality,
       * and sets the listener to call this view's scrollSpyExtras when
       * Bootstrap's "activate" event is called. This function should be called
       * anytime the DOM is updated.
       */
      renderScrollspy: function () {
        try {
          var view = this;
          var scrollSpyClass = "scrollspy-TOC-" + this.cid;
          var scrollSpyTarget = "." + scrollSpyClass;

          this.$el.addClass(scrollSpyClass);

          // Manually set scrollspy data,
          // see https://github.com/twbs/bootstrap/issues/20022#issuecomment-561376832
          var $spy = $("body").scrollspy({
            target: scrollSpyTarget,
            offset: 35,
          });
          var newSpyData = $spy.data();
          newSpyData.scrollspy.selector = scrollSpyTarget + " .nav li > a";
          $.fn.scrollspy.call($spy, newSpyData);
          $spy.scrollspy("process");
          $spy.scrollspy("refresh");

          // Remove any active classes to start
          var activeEls = this.$(scrollSpyTarget + " .active");
          activeEls.removeClass("active");

          // Add scroll spy
          $("body").off("activate");
          $("body").on("activate", function (e) {
            view.scrollSpyExtras(e);
          });
          $(window).off("resize");
          $(window).on("resize", function () {
            $spy.scrollspy("refresh");
          });
        } catch (e) {
          console.log("Error adding scrollspy! Error message: " + e);
        }
      },

      /**
       * Adds and refreshes bootstrap's affix functionality. This function
       * should be called after the DOM has been rendered or updated. Renamed
       * from postRender to avoid it being called automatically by Backbone.
       * @since 2.27.0
       */
      setAffix: function () {
        try {
          var isVisible = this.$el.find(":visible").length > 0;

          if (!isVisible || !this.$el.offset()) {
            return;
          }

          if (this.affix === true) {
            this.$el.affix({ offset: this.$el.offset().top });
          }

          if (this.addScrollspy) {
            this.renderScrollspy();
          }
        } catch (e) {
          console.log(
            "Error affixing the table of contents, error message: " + e,
          );
        }
      },

      /**
       * scrollSpyExtras - Adds extra functionality to Bootstrap's scrollSpy function.
       * This function is called anytime the "activate" event is called by bootstrap.
       * For the desktop TOC, if activates the parent LI in the case that a second-level
       * LI is active. For the mobile TOC, it changes text displayed in this.topLevelMobileToggle
       * and this.secondLevelMobileToggle to the active top-level and second-level item, respectively.
       * It also makes only the active second-level menu visible under the secondLevelMobile dropdown.
       *
       * @param  {event} e The "activate" event triggered when an LI element is activated by bootstrap's ScrollSpy
       */
      scrollSpyExtras: function (e) {
        try {
          if (e && e.target) {
            // console.log($(e.target)[0].innerText);

            var activeLI = $(e.target),
              mobileContainer = activeLI.closest(".mobile"),
              isTopLevel = activeLI.hasClass("top-level-item"),
              isMobile = mobileContainer && mobileContainer.length;

            // --- DESKTOP --- //

            // For the desktop nav, just highlight the parent item if the
            // activated item is a second-level item.
            if (!isMobile) {
              if (!isTopLevel) {
                activeLI.closest(".top-level-item").addClass("active");
              }
              return;
            }

            // --- MOBILE --- //

            var allSubmenus = mobileContainer.find(".submenu"),
              allToplevelLIs = mobileContainer.find(".top-level-item"),
              itemText = activeLI.find("a").text().trim(),
              index = activeLI.data("index"); // Used to match submenus to parent LIs.

            if (isTopLevel) {
              // Update the toggle text, hide submenu displays
              this.topLevelMobileToggle.text(itemText);
              this.secondLevelMobileToggle.text("");
              this.secondLevelMobileToggle
                .closest(".second-level-items")
                .addClass("hidden");
              this.mobileDivider.addClass("hidden");

              // Get the corresponding child submenu that should be active
              var activeSubMenu = _.filter(allSubmenus, function (submenu) {
                return $(submenu).data("index") == index;
              });
            } else {
              // Get the parent LI, make it active, and update the toggle text
              activeTopLI = _.filter(allToplevelLIs, function (topLI) {
                return $(topLI).data("index") == index;
              });
              $(activeTopLI).addClass("active");
              this.topLevelMobileToggle.text(
                $(activeTopLI).children("a").text().trim(),
              );
              this.secondLevelMobileToggle.text(itemText);

              // Find the menu this LI is within
              var activeSubMenu = activeLI.closest("ul");
            }

            // Hide all submenus so only max 1 is active at a time
            allSubmenus.addClass("hidden");

            // Ensure the active submenu & associated toggle text is shown
            if (activeSubMenu && activeSubMenu.length) {
              $(activeSubMenu).removeClass("hidden");
              this.secondLevelMobileToggle
                .closest(".second-level-items")
                .removeClass("hidden");
              this.mobileDivider.removeClass("hidden");
              if (this.secondLevelMobileToggle.text() == "") {
                this.secondLevelMobileToggle.text("Sub-sections");
              }
            }
          }
        } catch (error) {
          console.log(
            "error adding extra scrollSpy functionality to portal section, error message: " +
              error,
          );
        }
      },

      /**
       * toggleDropdown - Extends bootstrap's dropdown menu functionality by
       * hiding the dropdown menu when the user clicks the dropdown toggle or
       * any of the options within the dropdown menu.
       *
       * @param  {event} e The click event on any part of the dropdown element
       */
      toggleDropdown: function (e) {
        try {
          if (
            e &&
            e.target &&
            $(e.target).closest(".dropdown").children(".dropdown-menu")
          ) {
            // The entire dropdown element including toggle and menu
            var $dropdown = $(e.target).closest(".dropdown"),
              // The menu that we wish to show and hide on click
              $menu = $dropdown.children(".dropdown-menu");

            // Wait for bootstrap to add or remove the open class on $dropdown
            setTimeout(function () {
              if ($menu.hasClass("hidden") || $dropdown.hasClass("open")) {
                $menu.removeClass("hidden");
              } else {
                $menu.addClass("hidden");
              }
            }, 5);
          }
        } catch (error) {
          console.log(
            "error hiding TOC dropdown menu on click, error message: " + error,
          );
        }
      },

      /**
       * onClose - Close and destroy the view
       */
      onClose: function () {
        // Make sure to stop scrollSpy listeners
        $("body").off("activate");
        $(window).off("resize");
      },
    },
  );

  return TOCView;
});