Index

Sextant
Table des matières automatique

Une classe typeScript utilisée sur ce site pour dresser l’index des pages.

À faire

  • Documentation
  • Amélioration de la configuration

Limitation actuelles

La classe ne permet pas de rafraîchir l’index de la page en cours (duplicata d’ancre virtuelle).

Sources

Utilisation (sur cette page)
let sextant: Sextant = Sextant.GetInstance();
sextant.autonumerationEnabled = false;
sextant.topMargin = 50;
sextant.anchorsSelectors = ["h1", "h3", "h5"];
sextant.indexedContainerSelector = "#page-content article.post-full.mod--sextant";
sextant.navigatorContentSelector = "#sextant-navigator .sextant-navigator__content";
sextant.breadcrumbContentSelector = "#sextant-breadcrumb .sextant-breadcrumb__content";
sextant.buildAnchorsIndex();

sextant.initNavigatorTogglers('[data-ui-action="sextant-navigator-toggle"]');
sextant.navigatorOpenedState = false;

document.body.addEventListener(Sextant.EVENT_OPENED, () => {
	//...
});
Sextant.ts
import * as StringUtils from "../../north730/utils/StringUtils";

class Sextant {
  static EVENT_OPENED: string = "sextant_opened";
  //	---	singleton implementation
  private static _Instance: Sextant;
  public static GetInstance(): Sextant {
    if (Sextant._Instance) return Sextant._Instance;
    else return new Sextant();
  }

  public uiidDataAttr: string = "data-sextant-uiid";
  public anchorsSelectorsDataAttr: string = "data-sextant-picked-tags";
  public autonumerationDataAttr: string = "data-sextant-autonumeration";
  public levelDataAttr: string = "data-sextant-level";

  public indexedContainerSelector: string = "body";
  public navigatorContainerSelector: string = "#sextant-navigator";
  public navigatorContentSelector: string = ".sextant-navigator__content";
  public breadcrumbContainerSelector: string = "#sextant-breadcrumb";
  public breadcrumbContentSelector: string = ".sextant-breadcrumb__content";
  public anchorsSelectors: string[] = ["h1, h2", "h3", "h4"];

  public initializedStateClass: string = "state--initialized";
  public navigatorOpenedStateClass: string = "state--navigator-opened";
  public navigatorPreviewedStateClass: string = "state--navigator-previewed";

  public topMargin: number = 0;
  public navigatorOpenedStateAutoclose: boolean = true;
  public breadcrumbUpTo: number = 0;
  public autonumerationEnabled: boolean = true;
  public autonumerationCallback: (level: number, position: number, label: string) => string;

  private _uiidSeed: number = 0;
  private _anchorsIndex: HTMLElement[];
  private _pointer: HTMLElement;

  private _toggleNavigatorOpenedStateCallback: (e: Event) => void;
  private _toggleNavigatorOpenedAutocloseCallback: (e: Event) => void;
  private _toggleNavigatorPreviewedStateCallback: (e: Event) => void;
  private _scrollChangedCallback: () => void;

  constructor() {
    if (Sextant._Instance) {
      throw new Error("[Sextant] Direct call to constructor ; use singleton implementation with static method Sextant.GetInstance()");
    } else {
      Sextant._Instance = this;
    }

    this._toggleNavigatorOpenedStateCallback = this.toggleNavigatorOpenedState.bind(this);
    this._toggleNavigatorOpenedAutocloseCallback = this._toggleNavigatorOpenedAutoclose.bind(this);
    this._toggleNavigatorPreviewedStateCallback = this._toggleNavigatorPreviewedState.bind(this);
    this._scrollChangedCallback = this._scrollChanged.bind(this);

    if (!this.autonumerationCallback) this.autonumerationCallback = this._autonumerationDefaultCallback;
  }

  public get anchorsIndex(): HTMLElement[] {
    return this._anchorsIndex;
  }

  public buildAnchorsIndex() {
    let indexedContainer: HTMLElement;
    let uiid: string;
    let level: number;
    let anchor: HTMLElement;
    let currentCounterLevel: number = 0;
    let countersByLevel: number[] = [];

    indexedContainer = document.querySelector(this.indexedContainerSelector);
    if (!indexedContainer) return;

    if (indexedContainer.hasAttribute(this.anchorsSelectorsDataAttr)) {
      this.anchorsSelectors = indexedContainer.getAttribute(this.anchorsSelectorsDataAttr).split(",");
    }

    if (indexedContainer.hasAttribute(this.autonumerationDataAttr)) {
      this.autonumerationEnabled = indexedContainer.getAttribute(this.autonumerationDataAttr) === "1" || indexedContainer.getAttribute(this.autonumerationDataAttr).toLowerCase() === "true";
    }

    // initialize model model
    this.anchorsSelectors.forEach(() => {
      countersByLevel.push(0);
    });
    this._anchorsIndex = Array.from(indexedContainer.querySelectorAll(this.anchorsSelectors.join(", "))) as HTMLElement[];

    // update view
    this._anchorsIndex.forEach((el: HTMLElement) => {
      uiid = `${StringUtils.Slugify(el.textContent)}-${++this._uiidSeed}`;
      level = this._getIndexItemLevel(el);

      if (this.autonumerationEnabled) {
        if (level < currentCounterLevel) {
          countersByLevel.forEach((count: number, counterLevel: number) => {
            if (counterLevel > level) countersByLevel[counterLevel] = 0;
          });
        }
        currentCounterLevel = level;
        ++countersByLevel[level];
        el.textContent = this.autonumerationCallback(level, countersByLevel[level], el.textContent);
      }

      el.setAttribute(this.uiidDataAttr, uiid);
      el.setAttribute(this.levelDataAttr, level.toString());

      anchor = document.getElementById(uiid);
      if (!anchor) {
        anchor = document.createElement("a");
        anchor.setAttribute("id", uiid);
      }
      anchor.classList.add("sextant-anchor");
      el.parentNode.insertBefore(anchor, el);
    });

    this._initialize();

    this._updatePointer();

    this._updateIndexView();
    this._updatePointerView();
  }

  public get navigatorOpenedState(): boolean {
    return (document.querySelector("html") as HTMLElement).classList.contains(this.navigatorOpenedStateClass);
  }
  public set navigatorOpenedState(status: boolean) {
    let html: HTMLElement = document.querySelector("html") as HTMLElement;

    html.classList.toggle(this.navigatorOpenedStateClass, status);

    if (status) {
      html.addEventListener("click", this._toggleNavigatorOpenedAutocloseCallback);
      document.body.dispatchEvent(new Event(Sextant.EVENT_OPENED));
    } else {
      html.removeEventListener("click", this._toggleNavigatorOpenedAutocloseCallback);
    }
  }

  public get navigatorPreviewedState(): boolean {
    return (document.querySelector("html") as HTMLElement).classList.contains(this.navigatorPreviewedStateClass);
  }
  public set navigatorPreviewedState(status: boolean) {
    let html: HTMLElement = document.querySelector("html") as HTMLElement;

    html.classList.toggle(this.navigatorPreviewedStateClass, status);
  }

  public initNavigatorTogglers(selector: string) {
    let togglers: HTMLElement[] = Array.from(document.querySelectorAll(selector));

    togglers.forEach((el: HTMLElement) => {
      // reset... init...
      el.removeEventListener("mouseover", this._toggleNavigatorPreviewedStateCallback);
      el.addEventListener("mouseover", this._toggleNavigatorPreviewedStateCallback);

      el.removeEventListener("mouseout", this._toggleNavigatorPreviewedStateCallback);
      el.addEventListener("mouseout", this._toggleNavigatorPreviewedStateCallback);

      el.removeEventListener("click", this._toggleNavigatorOpenedStateCallback);
      el.addEventListener("click", this._toggleNavigatorOpenedStateCallback);
    });
  }

  public toggleNavigatorOpenedState(e: Event = null) {
    if (e) {
      e.stopImmediatePropagation();
    }
    this.navigatorOpenedState = !this.navigatorOpenedState;
  }

  private _initialize() {
    document.querySelector(this.navigatorContainerSelector).classList.add(this.initializedStateClass);
    document.querySelector(this.breadcrumbContainerSelector).classList.add(this.initializedStateClass);

    window.addEventListener("scroll", this._scrollChangedCallback);
    window.addEventListener("resize", this._scrollChangedCallback);
  }

  private _autonumerationDefaultCallback(level: number, position: number, label: string): string {
    const alphabet: string = " ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    let prefix = "";
    switch (level) {
      case 1:
        prefix = alphabet.substr(position, 1);
        prefix += ". ";
        break;
      case 2:
        prefix = StringUtils.Romanize(position) + ". ";
        break;
      case 3:
        prefix = position < 10 ? "0" + position.toString() : position.toString();
        prefix += ". ";
    }
    return prefix + label;
  }

  private _getIndexItemLevel(el: HTMLElement): number {
    for (let i: number = 0, ilen: number = this.anchorsSelectors.length; i < ilen; ++i) {
      if (el.matches(this.anchorsSelectors[i])) return i;
    }
    return -1;
  }

  private _updatePointer() {
    let bottomMargin: number;
    let lastPointer: HTMLElement;
    let currentPointer: HTMLElement;
    let rect: DOMRect;
    let rejectedByTop: boolean;
    let rejectedByBottom: boolean;

    bottomMargin = document.documentElement.clientHeight;
    bottomMargin -= 0.35 * bottomMargin;

    this._anchorsIndex.forEach((el: HTMLElement) => {
      rect = el.getBoundingClientRect();
      rejectedByTop = rect.top < this.topMargin;
      rejectedByBottom = rect.bottom > bottomMargin;

      el.classList.toggle("state--active", !rejectedByTop && !rejectedByBottom && !currentPointer);

      if (!rejectedByTop && !rejectedByBottom && !currentPointer) {
        currentPointer = el;
      } else if (rejectedByTop && !rejectedByBottom && !currentPointer) {
        lastPointer = el;
      }
    });

    this._pointer = currentPointer || lastPointer;
    this._updatePointerView();
  }

  private _updatePointerView() {
    let breadcrumb: HTMLElement;
    let navigator: HTMLElement;
    let path: HTMLElement[] = [];
    let pathStr: string[] = [];
    let currentLevel: number;

    breadcrumb = document.querySelector(this.breadcrumbContentSelector);
    if (breadcrumb) {
      if (this._pointer && this.breadcrumbUpTo >= 0) {
        path.push(this._pointer);
        currentLevel = parseInt(this._pointer.getAttribute(this.levelDataAttr));
        for (let i: number = this._anchorsIndex.indexOf(this._pointer), el: HTMLElement, elLevel: number; i > 0 && currentLevel > this.breadcrumbUpTo; --i) {
          el = this._anchorsIndex[i];
          elLevel = parseInt(el.getAttribute(this.levelDataAttr));
          if (currentLevel > elLevel) {
            currentLevel = elLevel;
            path.push(el);
          }
        }
      }

      path.forEach((el: HTMLElement) => {
        pathStr.unshift(`<span class="__token"><a href="#${el.getAttribute(this.uiidDataAttr)}">${el.textContent}</a></span>`);
      });

      breadcrumb.innerHTML = pathStr.length > 0 ? pathStr.join(" › ") : "";
    }

    navigator = document.querySelector(this.navigatorContentSelector);
    if (navigator) {
      navigator.querySelectorAll("li").forEach((el: HTMLElement) => {
        el.classList.toggle("state--active", this._pointer && this._pointer.getAttribute(this.uiidDataAttr) == el.getAttribute(this.uiidDataAttr));
      });
    }
  }

  private _updateIndexView() {
    let navigator: HTMLElement;
    let html: string;
    let uiid: string;

    navigator = document.querySelector(this.navigatorContentSelector);
    if (!navigator) return;

    html = "<ul>";
    this._anchorsIndex.forEach((el: HTMLElement) => {
      uiid = el.getAttribute(this.uiidDataAttr);
      html += `<li class="sextant-heading mod--level${el.getAttribute(this.levelDataAttr)}" ${this.uiidDataAttr}="${uiid}">
				<a href="#${uiid}">${el.textContent}</a>
				</li>`;
    });
    html += "</ul>";
    navigator.innerHTML = html;
  }

  private _toggleNavigatorOpenedAutoclose(e: Event) {
    let target: HTMLElement = e.target as HTMLElement;

    if (target.closest(this.navigatorContentSelector) === null && target.closest(this.breadcrumbContentSelector) === null) {
      this.navigatorOpenedState = false;
    }
  }

  private _toggleNavigatorPreviewedState(e: Event): void {
    this.navigatorPreviewedState = e.type === "mouseover";
  }

  private _scrollChanged() {
    this._updatePointer();
  }
}

export { Sextant };
StringUtils.ts
export const Slugify = (...args: (string | number)[]): string => {
  const value = args.join(" ");
  return (
    value
      // split an accented letter in the base letter and the accent, then remove
      .normalize("NFD")
      .replace(/[\u0300-\u036f]/g, "")
      .toLowerCase()
      .trim()
      // // remove all chars not letters, numbers and spaces (to be replaced)
      .replace(/[^a-z0-9 ]/g, "")
      .replace(/\s+/g, "-")
  );
};

export const Romanize = (num: number) => {
  if (isNaN(num)) return NaN;
  let digits: string[] = String(+num).split(""),
    key: string[] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM", "", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC", "", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"],
    roman: string = "",
    i: number = 3;
  while (i--) {
    roman = (key[+digits.pop() + i * 10] || "") + roman;
  }
  return roman;
};