MediaWiki:MobileUI.js

提供:Vikipedia
2019年11月20日 (水) 23:24時点における𩸽 (トーク | 投稿記録)による版

注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。

  • Firefox / Safari: Shift を押しながら 再読み込み をクリックするか、Ctrl-F5 または Ctrl-R を押してください (Mac では ⌘-R)
  • Google Chrome: Ctrl-Shift-R を押してください (Mac では ⌘-Shift-R)
  • Internet Explore/Edger: Ctrl を押しながら 最新の情報に更新 をクリックするか、Ctrl-F5 を押してください
  • Opera: Ctrl-F5を押してください
// @ts-check

/**
 * @typedef MediaWiki
 * @prop {readonly Map<string, any>} config
 */

/**
 * @typedef WGNamespaceIds
 * @prop {number} user
 * @prop {number} user_talk
 * @prop {number} special
 */

/**
 * @typedef {{ [id: string]: string; }} WGFormattedNamespaces
 */

/**
 * @typedef MenuDataItemComponent
 * @prop {string} [text]
 * @prop {string} [title]
 * @prop {string} [href]
 * @prop {string} [id]
 * @prop {string | string[]} [class]
 * @prop {string} [dataEventName]
 */

/**
 * @typedef MenuDataItem
 * @prop {string} [id]
 * @prop {string | string[]} [class]
 * @prop {MenuDataItemComponent[]} components
 */

/**
 * @typedef MenuDataGroup
 * @prop {string} [id]
 * @prop {string | string[]} [class]
 * @prop {MenuDataItem[]} items
 */

/**
 * @typedef MobileUI
 * @prop {MenuDataGroup[]} leftGroups
 * @prop {MenuDataGroup[]} rightGroups
 */

class MobileUIBuilder {
    /**
     * @param {MediaWiki} mw
     */
    constructor(mw) {
        // this.mw = mw;

        /**
         * @type {string}
         */
        this.wgServer = mw.config.get("wgServer");

        /**
         * @type {string}
         */
        this.wgScript = mw.config.get("wgScript");

        /**
         * @type {string}
         */
        this.wgScriptPath = mw.config.get("wgScriptPath");

        /**
         * @type {string}
         */
        this.wgPageName = mw.config.get("wgPageName");

        /**
         * @type {number}
         */
        this.wgArticleId = mw.config.get("wgArticleId");

        /**
         * @type {boolean}
         */
        this.wgIsArticle = mw.config.get("wgIsArticle");

        /**
         * @type {boolean}
         */
        this.wgIsMainPage = mw.config.get("wgIsMainPage");

        /**
         * @type {number}
         */
        this.wgUserId = mw.config.get("wgUserId");

        /**
         * @type {string}
         */
        this.wgUserName = mw.config.get("wgUserName");

        /**
         * @type {WGNamespaceIds}
         */
        this.wgNamespaceIds = Object.assign({}, mw.config.get("wgNamespaceIds"));

        /**
         * @type {WGFormattedNamespaces}
         */
        this.wgFormattedNamespaces = Object.assign({}, mw.config.get("wgFormattedNamespaces"));
    }

    /**
     * @param {MenuDataItemComponent} component
     * @returns {string}
     */
    hrefForMenuDataItemComponent(component) {
        switch (component.id) {
            case "mobile-ui-page-short-url":
                return [
                    this.wgServer,
                    this.wgScriptPath,
                    "/?curid=",
                    String(this.wgArticleId),
                ].join("");

            case "mobile-ui-page-special-user":
                return [
                    this.wgScriptPath,
                    "/",
                    this.wgFormattedNamespaces[this.wgNamespaceIds.user],
                    ":",
                    this.wgUserName,
                ].join("");

            case "mobile-ui-page-special-contributions":
                return [
                    this.wgScriptPath,
                    "/",
                    this.wgFormattedNamespaces[this.wgNamespaceIds.special],
                    ":",
                    "Contributions",
                    "/",
                    this.wgUserName,
                ].join("");

            case "mobile-ui-page-user-talk":
                return [
                    this.wgScriptPath,
                    "/",
                    this.wgFormattedNamespaces[this.wgNamespaceIds.user_talk],
                    ":",
                    this.wgUserName,
                ].join("");

            case "mobile-ui-page-special-logout":
                return [
                    this.wgScript,
                    "?title=",
                    this.wgFormattedNamespaces[this.wgNamespaceIds.special],
                    ":",
                    "Logout",
                    "&returnto=",
                    this.wgPageName,
                ].join("");

            default:
                return component.title
                    ? (this.wgScriptPath + "/" + component.title)
                    : component.href || "";
        }
    }

    /**
     * @param {MenuDataItemComponent} component
     * @returns {HTMLAnchorElement}
     */
    menuDataItemComponent(component) {
        const href = this.hrefForMenuDataItemComponent(component);

        const element = window.document.createElement("a");
        element.id = component.id || "";
        element.classList.add(...[].concat(component.class || []));
        element.setAttribute("data-event-name", component.dataEventName || "");

        if (href) {
            element.href = encodeURI(href);
        }

        const textElement = window.document.createElement("span");
        textElement.textContent = component.text || href;
        element.append(textElement);

        return element;
    }

    /**
     * @param {MenuDataItem} item
     * @returns {HTMLLIElement}
     */
    menuDataItem(item) {
        /**
         * @type {HTMLAnchorElement[]}
         */
        const children = [];

        for (const component of item.components) {
            children.push(this.menuDataItemComponent(component));
        }

        const element = window.document.createElement("li");
        element.id = item.id || "";
        element.classList.add(...[].concat(item.class || []));
        element.append(...children);

        return element;
    }

    /**
     * @param {MenuDataGroup} group
     * @returns {HTMLUListElement}
     */
    menuDataGroup(group) {
        /**
         * @type {HTMLLIElement[]}
         */
        const children = [];

        for (const item of group.items) {
            children.push(this.menuDataItem(item));
        }

        const element = window.document.createElement("ul");
        element.id = group.id || "";
        element.classList.add(...[].concat(group.class || []));
        element.append(...children);

        return element;
    }

    /**
     * @param {MenuDataGroup[]} groups
     * @returns {HTMLUListElement[]}
     */
    menuDataGroups(groups) {
        /**
         * @type {HTMLUListElement[]}
         */
        const children = [];

        for (const group of groups) {
            children.push(this.menuDataGroup(group));
        }

        return children;
    }

    /**
     * @returns {HTMLElement}
     */
    static get leftMenuContainerElement() {
        return window.document
            .getElementById("mw-mf-page-left");
    }

    /**
     * @returns {HTMLDivElement | null}
     */
    static get leftMenuElement() {
        return /** @type {HTMLDivElement} */ (
            MobileUIBuilder
                .leftMenuContainerElement
                .getElementsByClassName(MobileUIBuilder.menuClass)
                .item(0)
        );
    }

    /**
     * @returns {HTMLBodyElement | null}
     */
    static get authenticatedBody() {
        return /** @type {HTMLBodyElement} */ (
            window.document
                .getElementsByClassName("is-authenticated")
                .item(0)
        );
    }

    /**
     * @returns {HTMLFormElement}
     */
    static get headerElement() {
        return /** @type {HTMLFormElement} */ (
            window.document
                .getElementsByClassName("header")
                .item(0)
        );
    }

    /**
     * @returns {HTMLAnchorElement | null}
     */
    static get userButton() {
        return /** @type {HTMLAnchorElement} */ (
            MobileUIBuilder
                .headerElement
                .getElementsByClassName("user-button")
                .item(0)
        );
    }

    /**
     * @returns {HTMLButtonElement | null}
     */
    static get rightMenuButtonElement() {
        return /** @type {HTMLButtonElement} */ (
            window.document
                .getElementById(MobileUIBuilder.rightMenuButtonID)
        );
    }

    /**
     * @returns {this}
     */
    setNotArticleClass() {
        if (this.wgIsArticle && !this.wgIsMainPage) return this;

        MobileUIBuilder
            .leftMenuContainerElement
            .classList
            .add("mobile-ui-not-article");

        return this;
    }

    /**
     * @returns {this}
     */
    assembleRightMenuButton() {
        if (!this.wgUserId) return this;
        if (!MobileUIBuilder.authenticatedBody) return this;
        if (MobileUIBuilder.rightMenuButtonElement) return this;

        const userButton = MobileUIBuilder.userButton;
        if (userButton && !userButton.text.trim()) {
            userButton.parentElement.classList.add(MobileUIBuilder.hiddenClass);
        }

        const button = window.document.createElement("button");
        button.id = MobileUIBuilder.rightMenuButtonID;
        button.classList.add(
            "mw-ui-icon",
            "mw-ui-icon-element",
            "mobile-ui-icon-puzzle",
        );

        button.addEventListener("click", function (event) {
            event.preventDefault();
            event.stopPropagation();
            this.blur();

            const body = MobileUIBuilder.authenticatedBody;
            if (!body) return;
            if (body.classList.contains(MobileUIBuilder.navigationEnabledClass)) return;

            body.classList.add(
                MobileUIBuilder.navigationEnabledClass,
                "secondary-navigation-enabled",
            );
        });

        const buttonWrapper = window.document.createElement("div");
        buttonWrapper.append(button);
        MobileUIBuilder.headerElement.append(buttonWrapper);

        return this;
    }

    /**
     * @returns {this}
     */
    prebuild() {
        return this
            .setNotArticleClass()
            .assembleRightMenuButton();
    }

    /**
     * @param {MobileUI} mobileUI
     * @param {HTMLElement} leftMenuContainer
     * @param {HTMLDivElement} leftMenu
     * @returns {this}
     */
    modify(mobileUI, leftMenuContainer, leftMenu) {
        const firstGroup = leftMenu.getElementsByTagName("ul").item(0);
        if (firstGroup) {
            firstGroup.classList.add(MobileUIBuilder.hiddenClass);
        }

        const customLeftGroups = this.menuDataGroups(mobileUI.leftGroups);
        leftMenu.prepend(...customLeftGroups);

        const viewport = leftMenuContainer.parentElement;

        const rightMenuContainer = window.document.createElement("nav");
        rightMenuContainer.id = "mobile-ui-page-right";
        rightMenuContainer.classList.add(...leftMenuContainer.classList);
        viewport.insertBefore(rightMenuContainer, leftMenuContainer);

        const rightMenu = window.document.createElement("div");
        rightMenu.classList.add(...leftMenu.classList);
        rightMenuContainer.appendChild(rightMenu);

        const rightGroups = this.menuDataGroups(mobileUI.rightGroups);
        rightMenu.append(...rightGroups);

        return this;
    }

    /**
     * @param {MobileUI} mobileUI
     * @returns {this}
     */
    build(mobileUI) {
        const leftMenuContainer = window.document.getElementById("mw-mf-page-left");

        const leftMenu = /** @type {HTMLDivElement} */ (
            leftMenuContainer.getElementsByClassName(MobileUIBuilder.menuClass).item(0)
        );

        if (MobileUIBuilder.leftMenuElement) {
            this.modify(mobileUI, leftMenuContainer, leftMenu);
            return this;
        }

        const observer = new MutationObserver((mutations, observer) => {
            for (const record of mutations) {
                for (const node of record.addedNodes) {
                    const maybeLeftMenu = /** @type {HTMLDivElement} */ (node);
                    const { classList } = maybeLeftMenu;
                    if (classList && classList.contains(MobileUIBuilder.menuClass)) {
                        observer.disconnect();
                        this.modify(mobileUI, leftMenuContainer, maybeLeftMenu);
                        return;
                    }
                }
            }
        });

        observer.observe(leftMenuContainer, { childList: true });

        return this;
    }
}

MobileUIBuilder.rightMenuButtonID = "mobile-ui-right-menu-button";
MobileUIBuilder.menuClass = "menu";
MobileUIBuilder.hiddenClass = "mobile-ui-hidden";
MobileUIBuilder.navigationEnabledClass = "navigation-enabled";