menusystem-component/MenuSystem.js

/**
 *  MenuSystem
 *  A system to dynamically register into a menu structure and present it visually
 * 
 *  Copyright 2020, 2021 Rolf Bagge, Janus B. Kristensen, CAVI,
 *  Center for Advanced Visualization and Interacion, Aarhus University
 *    
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0

 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
**/

/**
 * @namespace MenuSystem
 */
window.MenuSystem = {};

/**
 * @typedef {Object} MenuSystem~Point
 * @property {Number} x
 * @property {Number} y
 */

/**
 * @typedef {Object} MenuSystem.Menu~menuConfig
 * @property {MenuSystem.MenuBuilder} [builder=null] - The MenuBuilder to use when instanciating the Menu, if null the DefaultBuilder will be used instead.
 * @property {*} [context=null] - The context of this Menu
 * @property {Boolean} [keepOpen=false] - Should the Menu stay open when clicked
 * @property {Boolean} [groupDividers=false] - Should group dividers be visible
 * @property {function} [onOpen] - Called when the Menu is opened
 * @property {function} [onClose] - Called when the Menu is closed
 */

/**
 * @typedef {Object} MenuSystem.MenuItem~menuItemConfig
 * @property {String} [label] - The label of this MenuItem
 * @property {*} [icon] - The icon of this MenuItem
 * @property {Number} [order=99999] - The order of this MenuItem
 * @property {function} [onAction] - The callback to call when this MenuItem has its action triggered
 * @property {function} [onOpen] - Called when the Menu this MenuItem belongs to is about to open. If false is returned, this MenuItem will not be shown.
 * @property {MenuSystem.Menu} [submenu=null] - A submenu to open when clicking this MenuItem
 * @property {boolean} [submenuOnHover=true] - Determines if this MenuItem's submenu should open on hover
 * @property {String} [group] - The group this MenuItem should belong too
 * @property {Number} [groupOrder=99999] - If this MenuItem is part of a group, this is the order the group should have. (The lowest order of all members is choosen)
 */

/**
 * MenuManager is used to interact with menus in MenuSystem
 * @hideconstructor
 * @memberof MenuSystem
 */
MenuSystem.MenuManager = class MenuManager {
    /**
     * Registers a new MenuItem for the given named menu. MenuItem's can be registered both before and after the menu is created.
     * @param {String} menuName - The name of the menu to register this MenuItem for
     * @param {MenuSystem.MenuItem~menuItemConfig} menuItemConfig - The configuration for this MenuItem
     *
     * @returns {Object} - Delete object, with a single method delete(), that removes this menuItemConfig and all menuitems associated with it
     *
     * @example
     * MenuSystem.MenuManager.registerMenuItem("MyMenu", {
     *     label: "MyMenuItem",
     *     order: 10.
     *     onAction: ()=>{console.log("MyMenuItem clicked!");}
     *     onOpen: ()=>{return true;}
     * });
     */
    static registerMenuItem(menuName, menuItemConfig) {
        menuItemConfig._allMenuItems = new Set();

        //Register this MenuItem in the set
        let menuItemSet = MenuSystem.MenuManager.menuItemMap.get(menuName);
        if(menuItemSet == null) {
            menuItemSet = new Set();
            MenuSystem.MenuManager.menuItemMap.set(menuName, menuItemSet);
        }
        menuItemSet.add(menuItemConfig);

        //Add MenuItem to all menus already created
        let menuArray = MenuSystem.MenuManager.menuMap.get(menuName);
        if(menuArray != null) {
            menuArray.forEach((menu)=>{
                menuItemConfig._allMenuItems.add(menu.addItem(menuItemConfig));
            });
        }

        return {
            delete: ()=>{
                menuItemSet.delete(menuItemConfig);

                menuItemConfig._allMenuItems.forEach((menuItem)=>{
                    menuItem.menu.removeItem(menuItem);
                });
            }
        }
    }

    /**
     * Create a new menu
     * @param {String} menuName - The name of the Menu
     * @param {MenuSystem.Menu~menuConfig} [menuConfig] - The configuration of the Menu
     * @return {MenuSystem.Menu} - The created menu
     * 
     * @example
     * MenuSystem.MenuManager.createMenu("MyMenu", {
     *     context: myContext,
     *     builder: MenuSystem.MyLovelyMenuBuilder
     *     keepOpen: false,
     *     onOpen: ()=>{console.log("MyMenu was opened!")},
     *     onClose: ()=>{console.log("MyMenu was closed!")},
     * });
     */
    static createMenu(menuName, menuConfig = {}) {

        if(menuConfig.builder == null) {
            if(MenuSystem.MenuManager.defaultBuilder == null) {
                throw "Attempt to create Menu with defaultBuilder but no default builder is set!";
            }

            menuConfig.builder = MenuSystem.MenuManager.defaultBuilder;
        }

        //Build Menu
        let menu = new MenuSystem.Menu(menuConfig);

        //Add it to our map of named menus
        let menuArray = MenuSystem.MenuManager.menuMap.get(menuName);
        if(menuArray == null) {
            menuArray = [];
            MenuSystem.MenuManager.menuMap.set(menuName, menuArray);
        }
        menuArray.push(menu);

        //Add all registered menuItems
        let menuItemSet = MenuSystem.MenuManager.menuItemMap.get(menuName);
        if(menuItemSet != null) {
            menuItemSet.forEach((itemConfig)=>{
                itemConfig._allMenuItems.add(menu.addItem(itemConfig));
            });
        }

        return menu;
    }

    static setDefaultBuilder(builder) {
        MenuSystem.MenuManager.defaultBuilder = builder;
    }
};
MenuSystem.MenuManager.menuItemMap = new Map();
MenuSystem.MenuManager.menuItemDeleterMap = new Map();
MenuSystem.MenuManager.menuMap = new Map();
MenuSystem.MenuManager.defaultBuilder = null;

/**
 * MenuBuilder is used to build concrete instances of Menu's
 */
MenuSystem.MenuBuilder = class MenuBuilder {
    /**
     * Construct the HTML code for the given Menu
     * @param {MenuSystem.Menu} menu
     */
    static buildMenuHtml(menu) {
        //Override in subclass
        throw "Remember to override buildMenuHtml()";
    }

    /**
     * Construct the HTML code for the given MenuItem
     * @param {MenuSystem.MenuItem} menuItem 
     */
    static buildMenuItemHtml(menuItem) {
        //Override in subclass
        throw "Remember to override buildMenuItemHtml()";
    }

    /**
     * Clear all MenuItem's from the given Menu
     * @param {MenuSystem.Menu} menu
     */
    static clearMenuItems(menu) {
        //Override in subclass
        throw "Remember to override clearMenuItems()";
    }

    /**
     * Attach the given MenuItem's to the given Menu
     * @param {MenuSystem.Menu} menu 
     * @param {MenuSystem.MenuItem[][]} groups - Array of MenuItem arrays, where each outer array is a grouping of MenuItems
     */
    static attachMenuItems(menu, menuItems) {
        //Override in subclass
        throw "Remember to override attachMenuItems()";
    }

    /**
     * Opens the given Menu
     * 
     * The source parameter tells where the open event originated from, which can be a Element, or a 2D point
     * @param {MenuSystem.Menu} menu - The Menu to open
     * @param {Element|MenuSystem~Point} source - The source of the open event
     */
    static open(menu, source) {
        //Override in subclass
        throw "Remember to override open()";
    }

    /**
     * Closes the given Menu
     * @param {MenuSystem.Menu} menu 
     */
    static close(menu) {
        //Override in subclass
        throw "Remember to override close()";
    }

    /**
     * Destroy the given Menu
     * @param {MenuSystem.Menu} menu 
     */
    static destroyMenu(menu) {
        //Override in subclass
        throw "Remember to override destroyMenu()";
    }

    /**
     * Destroy the given MenuItem
     * @param {MenuSystem.MenuItem} menuItem
     */
    static destroyMenuItem(menuItem) {
        //Override in subclass
        throw "Remember to override destroyMenuItem()";
    }

    /**
     * Create an icon that represents a submenu
     */
    static createSubmenuIcon() {
        //Override in subclass
        throw "Remember to override createSubmenuIcon()";
    }
};

/**
 * Represents a Menu in the MenuSystem
 */
MenuSystem.Menu = class Menu {
    /**
     * Construct a new Menu
     * @param {MenuSystem.Menu~menuConfig} [options] - The options to use for this menu
     */
    constructor(options) {
        this.options = options;

        this.context = WebstrateComponents.Tools.fromConfig(options, "context", null);
        this.builder = WebstrateComponents.Tools.fromConfig(options, "builder", null);

        this.keepOpen = WebstrateComponents.Tools.fromConfig(options, "keepOpen", false);

        this.groupDividers = WebstrateComponents.Tools.fromConfig(options, "groupDividers", false);

        this.growDirection = WebstrateComponents.Tools.fromConfig(options, "growDirection", MenuSystem.Menu.GrowDirection.RIGHT);
        this.layoutDirection = WebstrateComponents.Tools.fromConfig(options, "layoutDirection", MenuSystem.Menu.LayoutDirection.VERTICAL);
        this.layoutWrapping = WebstrateComponents.Tools.fromConfig(options, "layoutWrapping", true);
        this.layoutCompact = WebstrateComponents.Tools.fromConfig(options, "layoutCompact", false);
        this.defaultFocus = WebstrateComponents.Tools.fromConfig(options, "defaultFocus", true);

        this.onOpen = new Set();
        let openCallback = WebstrateComponents.Tools.fromConfig(options, "onOpen");
        if(openCallback != null) {
            this.onOpen.add(openCallback);
        }

        this.onClose = new Set();
        let closeCallback = WebstrateComponents.Tools.fromConfig(options, "onClose");
        if(closeCallback != null) {
            this.onClose.add(closeCallback);
        }

        this.menuItems = [];

        this.isOpen = false;
        this.currentSubmenu = null;

        /** @member {Element} - The DOM Element of this Menu */
        this.html = this.builder.buildMenuHtml(this);
        this.html.menu = this;
        this.html.classList.add("menusystem-menu");

        let self = this;

        this.html.addEventListener("contextmenu", (evt)=>{
            evt.preventDefault();
        });

        this.bodyClickHandler = function(evt) {
            self.handleBodyClick(evt);
        }

        if(this.keepOpen) {
            this.open();
        }
    }

    /**
     * Register a callback to be called when this Menu opens
     * @param {Function} callback 
     */
    registerOnOpenCallback(callback) {
        this.onOpen.add(callback);
    }

    /**
     * Deregister a callback to be called when this Menu opens
     * @param {Function} callback 
     */
    deregisterOnOpenCallback(callback) {
        this.onOpen.delete(callback);
    }

    /**
     * Register a callback to be called when this Menu closes
     * @param {Function} callback 
     */
    registerOnCloseCallback(callback) {
        this.onClose.add(callback);
    }

    /**
     * Deregister a callback to be called when this Menu closes
     * @param {Function} callback 
     */
    deregisterOnCloseCallback(callback) {
        this.onClose.delete(callback);
    }

    /**
     * Add a MenuItem to this Menu
     * @param {MenuSystem.MenuItem~menuItemConfig} itemConfig
     * @return {MenuSystem.MenuItem} - The added item
     */
    addItem(itemConfig) {
        let menuItem = new MenuSystem.MenuItem(this.builder, this, itemConfig);
        this.menuItems.push(menuItem);

        this.checkUpdate();

        return menuItem;
    }

    /**
     * Remove a MenuItem from this Menu
     * @param {MenuSystem.MenuItem} menuItem - The menu item to remove
     */
    removeItem(menuItem) {
        this.menuItems.splice(this.menuItems.indexOf(menuItem), 1);

        this.checkUpdate();
    }

    /**
     * Opens this Menu
     * @param {Element|MenuSystem~Point} [source] - Some notion of the source that openend the Menu, could be a Element or a Point
     */
    open(source = null) {
        let self = this;

        if(this.closeTimeoutId != null) {
            clearTimeout(this.closeTimeoutId);
            this.closeTimeoutId = null;
        }

        let numItems = this.update();

        if(numItems === 0 && !this.keepOpen) {
            return;
        }

        this.builder.open(this, source);

        this.isOpen = true;

        setTimeout(()=>{
            window.addEventListener("mouseup", self.bodyClickHandler, {
                capture: true
            });
        }, 0);

        this.triggerOnOpen();
    }

    checkUpdate() {
        if(this.isOpen) {
            let numItems = this.update();

            if(numItems === 0 && !this.keepOpen) {
                this.close();
            }
        }
    }

    /**
     * Updates the MenuItems in this menu.
     * @returns {number} - The number of items in the menu
     */
    update() {
        let self = this;

        this.builder.clearMenuItems(this);

        let groupMap = new Map();

        //Filter menu items
        let filteredMenuItems = this.menuItems.filter((item)=>{
            try {
                return item.onOpen(self, item);
            } catch (ex){
                console.warn("MenuSystem: Menu entry caused an exception while evaluating visibility, dropping: ", item, ex);
                return false;
            }
        });

        //Group items
        filteredMenuItems.forEach((item)=>{
            let group = groupMap.get(item.group);
            if(group == null) {
                group = [];
                groupMap.set(item.group, group);
            }

            group.push(item);
        });

        let groupArrays = Array.from(groupMap.values());

        //Sort Groups
        groupArrays.sort((g1, g2)=>{
            let g1MinGroupOrder = 99999999999;
            let g2MinGroupOrder = 99999999999;

            g1.forEach((item)=>{
                g1MinGroupOrder = Math.min(g1MinGroupOrder, item.groupOrder);
            });
            g2.forEach((item)=>{
                g2MinGroupOrder = Math.min(g2MinGroupOrder, item.groupOrder);
            });

            return g1MinGroupOrder - g2MinGroupOrder;
        });

        //Sort items in each group
        groupArrays.forEach((group)=>{
            group.sort((i1, i2) => {
                return i1.order - i2.order;
            });
        });

        this.builder.attachMenuItems(this, groupArrays);

        return filteredMenuItems.length;
    }

    /**
     * Close this Menu
     */
    close() {
        if(!this.isOpen) {
            return;
        }

        this.builder.close(this);

        this.isOpen = false;

        window.removeEventListener("mouseup", this.bodyClickHandler, {
            capture: true
        });

        this.triggerOnClose();
    }

    /**
     * Destroy this menu
     */
    destroy() {
        this.menuItems.forEach((item)=>{
            item.destroy();
        });

        this.builder.destroyMenu(this);
    }

    /**
     * Trigger the onOpen callback attached to this Menu
     * @private
     */
    triggerOnOpen() {
        let self = this;
        this.onOpen.forEach((callback)=>{
            if(typeof callback === "function") {
                try {
                    callback(self);
                } catch(e) {
                    console.error("Error inside onOpen:", e);
                }
            }
        });
    }

    /**
     * Trigger the onClose callback attached to this Menu
     * @private
     */
    triggerOnClose() {
        let self = this;
        this.onClose.forEach((callback)=>{
            if(typeof callback === "function") {
                try {
                    callback(self);
                } catch(e) {
                    console.error("Error inside onClose:", e);
                }
            }
        });
    }

    /**
     * Closes all submenus except on the given MenuItem
     * @param {MenuSystem.MenuItem} menuItem
     */
    closeAllOtherSubmenus(menuItem) {
        this.menuItems.forEach((item)=>{
            if(item != menuItem && item.submenu != null && item.submenu.isOpen) {
                item.toggleSubmenu();
            }
        });
    }

    /**
     * Signals to this Menu that the given MenuItem has been triggered
     * @private
     * @param {MenuSystem.MenuItem} menuItem 
     */
    handleItemAction(menuItem) {
        menuItem.triggerOnAction();

        if(!this.keepOpen && menuItem.submenu == null) {
            this.close();
        }
    }

    /**
     * Handles clicks to the document, to check if we should close if clicked outside
     * @private
     * @param {MouseEvent} evt 
     */
    handleBodyClick(evt) {
        let self = this;

        if(!WebstrateComponents.Tools.isInsideElement(this.html, evt.target)) {
            this.closeTimeoutId = setTimeout(()=>{
                this.closeTimeoutId = null;

                let shouldClose = !self.keepOpen && self.currentSubmenu == null;

                if(shouldClose) {
                    self.close();
                }
            }, 0);
        }
    }
};

MenuSystem.Menu.GrowDirection = {
    RIGHT: "right",
    DOWN: "down"
};
MenuSystem.Menu.LayoutDirection = {
    HORIZONTAL: "horizontal",
    VERTICAL: "vertical"
};

/**
 * Represents a MenuItem in the MenuSystem
 */
MenuSystem.MenuItem = class MenuItem {
    /**
     * Construct a new MenuItem
     * @param {MenuSystem.MenuBuilder} builder 
     * @param {MenuSystem.Menu} menu 
     * @param {MenuSystem.MenuItem~menuItemConfig} config 
     */
    constructor(builder, menu, config) {
        let self = this;

        this.builder = builder;
        this.config = config;
        this.menu = menu;

        this.order = WebstrateComponents.Tools.fromConfig(config, "order", 99999);
        this.icon = WebstrateComponents.Tools.fromConfig(config, "icon", null);
        this.metaIcon = WebstrateComponents.Tools.fromConfig(config, "metaIcon", null);
        this.label = WebstrateComponents.Tools.fromConfig(config, "label", null);
        this.tooltip = WebstrateComponents.Tools.fromConfig(config, "tooltip", null);
        this.onAction = WebstrateComponents.Tools.fromConfig(config, "onAction", ()=>{});
        this.onOpen = WebstrateComponents.Tools.fromConfig(config, "onOpen", ()=>{return true;});
        this.submenuOnHover = WebstrateComponents.Tools.fromConfig(config, "submenuOnHover", true);
        this.group = WebstrateComponents.Tools.fromConfig(config, "group", null);
        this.groupOrder = WebstrateComponents.Tools.fromConfig(config, "groupOrder", 99999);
        this.class = WebstrateComponents.Tools.fromConfig(config, "class", null);
        this.checked = WebstrateComponents.Tools.fromConfig(config, "checked", null);

        this.submenu = WebstrateComponents.Tools.fromConfig(config, "submenu", null);

        if(this.submenu != null && this.metaIcon == null) {
            this.metaIcon = this.builder.createSubmenuIcon();
        }

        /** @member {Element} - The DOM Element of this MenuItem */
        this.html = builder.buildMenuItemHtml(this);

        if(this.class != null) {
            this.html.classList.add(this.class.split(" "));
        }

        this.submenuCloseHandler = () => {
            if(self.menu.currentSubmenu === self.submenu) {
                if(!self.menu.keepOpen) {
                    self.menu.close();
                }
                self.menu.currentSubmenu = null;
            }
        }

        this.menuCloseHandler = () => {
            if(self.menu.currentSubmenu === self.submenu) {
                self.menu.currentSubmenu = null;
                self.submenu.close();
            }
        }

        this.setupSubmenuHover();
    }

    get active() {
        return this.html.classList.contains("MenuSystem_MenuItem_Active");
    }

    set active(active) {
        this.builder.setItemActive(this, active);
    }

    /**
     * Trigger the onAction callback of this MenuItem
     * @private
     */
    triggerOnAction() {
        let self = this;

        if(typeof this.onAction === "function") {
            this.onAction(this);
        }

        if(this.submenu != null) {
            this.toggleSubmenu();
        }
    }

    /**
     * Toggles the submenu of this MenuItem, undefined behaviour if this MenuItem is not currently showing in a menu
     * @private
     */
    toggleSubmenu() {
        if(this.submenu == null) {
            throw "Tried to toggle submenu on a menuitem that has no submenu!";
        }

        //Find top component after html
        let parent = this.html;
        while(parent.parentNode != null && !parent.parentNode.matches("html")) {
            parent = parent.parentNode;
        }

        parent.appendChild(this.submenu.html);

        if(this.submenu.isOpen) {
            this.menu.deregisterOnCloseCallback(this.menuCloseHandler);
            this.submenu.deregisterOnCloseCallback(this.submenuCloseHandler);
            this.submenu.close();
            this.submenu.superMenu = null;
            this.menu.currentSubmenu = null;
        } else {
            this.submenu.open(this.html);
            this.submenu.superMenu = this.menu;
            this.menu.currentSubmenu = this.submenu;
            this.submenu.registerOnCloseCallback(this.submenuCloseHandler);
            this.menu.registerOnCloseCallback(this.menuCloseHandler);
        }
    }

    /**
     * Destroys this MenuItem
     */
    destroy() {
        this.builder.destroyMenuItem(this);
    }

    /**
     * @private
     */
    setupSubmenuHover() {
        let self = this;

        let submenuHoverTimerId = null;

        this.html.addEventListener("mouseenter", (evt)=>{
            //Close all other submenus from our Menu
            self.menu.closeAllOtherSubmenus(self);

            if(this.submenuOnHover && this.submenu != null && !this.submenu.isOpen) {
                self.toggleSubmenu();
            }
        });

        this.html.addEventListener("mouseleave", (evt)=>{
            let newTarget = document.elementFromPoint(evt.pageX, evt.pageY);

            let menuItemRect = self.html.getBoundingClientRect();
            let submenuRect = null;

            if(this.submenu?.html != null) {
                submenuRect = self.submenu.html.getBoundingClientRect();

                let boxBetween = {
                    xMin: menuItemRect.x+menuItemRect.width - 5,
                    xMax: submenuRect.x + 5,
                    yMin: Math.min(submenuRect.y-5, menuItemRect.y - 5),
                    yMax: Math.max(submenuRect.y+submenuRect.height + 5, menuItemRect.y+menuItemRect.height + 5)
                }

                if(evt.pageX > boxBetween.xMin && evt.pageX < boxBetween.xMax && evt.pageY < boxBetween.yMax && evt.pageY > boxBetween.yMin) {
                    return;
                }
            }

            if(self.submenuOnHover && self.submenu != null && self.submenu.isOpen && !WebstrateComponents.Tools.isInsideElement(self.submenu.html, newTarget)) {
                self.toggleSubmenu();
            }
        });
    }
};