Search code examples
mithril.js

How to integrate custom context menus in Mithril


I'm trying to add custom contextmenus to some of the elements in a page and did it like this in a view which contains a table. The contextmenu is attached to the table header with the name "S":

list.view = function(ctrl, args) {

var contextMenuSelection =      
    m("div", {
    id : "context-menu-bkg-01",
    class : ctrl.isContextMenuVisible() === ctrl.contextMenuId ? "context-menu" : "hide",
    style : ctrl.contextMenuPosition(),
}, [ m("#select.menu-item.allow-hover", {
    onclick : function(e) {
        args.model.callMenu({
            cmdName : this.id
        })
    }
}, "Select all"), m("#deselect.menu-item.allow-hover", {
    onclick : function(e) {
        args.model.callMenu({
            cmdName : this.id
        })
    }
}, "Deselect all"), m("#invertSel.menu-item.allow-hover", {
    onclick : function(e) {
        args.model.callMenu({
            cmdName : this.id
        })
    }
}, "Invert selection") ]);

var table = m("table", [
    m("tr", [ m("th", {
        id : ctrl.contextMenuId,
        config : ctrl.configContextMenu(),
        oncontextmenu : function(e) {
            console.log("2021 contextMenuShow")
            e.preventDefault()
            var coords = utils.getCoords(e)
            var pos = {}
            pos.left = coords[0] + "px"
            pos.top = coords[1] + "px"
            ctrl.contextMenuPosition(pos)
            var id = e.currentTarget.id
            ctrl.isContextMenuVisible(id)
        }
        }, "S"),
            m("th[data-sort-by=pName]", "Name"),
            m("th[data-sort-by=pSize]", "Size"),
            m("th[data-sort-by=pPath]", "Path"),
            m("th[data-sort-by=pMedia]", "Media") ]),
        ctrl.items().map(
           function(item, idx) {
              return m("tr", ctrl.initRow(item, idx), {
              key : item.guid
              }, [ m("input[type=checkbox]", {
                id : item.guid,
                checked : ctrl.isSelected(item.guid)
                }),
                                    m("td", item.pName),
                m("td",  utils.numberWithDots(item.pSize)),
                m("td", item.pPath), m("td", item.pMedia) ])
            }) ])

return m("div", [contextMenuSelection, table])              
}

To get the contextmenu closed after the escape key is hit or the user clicks somewhere in the page with the mouse, this function is attached to the element via the config attribute:

ctrl.configContextMenu = function() { 
    return function(element, isInitialized, context) {
        console.log("1220 isInitialized=" + isInitialized)
        if(!isInitialized) {
           console.log("1225")
           document.addEventListener('click', function() {
              m.startComputation()
              ctrl.contextMenuVisibility(0)
              m.endComputation()
           }, false);
               document.addEventListener('keydown', function() {
              console.log("1235")
              m.startComputation()
                          ctrl.contextMenuVisibility(0)
              m.endComputation()
           }, false)
        }
    };  
};

The behavior is unpredictable: If the table is empty, the custom contextmenu shows up and is hidden as expected. If the table is populated, the default contextmenu is shown instead.

Using a debugger and some breakpoints didn't get me some information what is happening except that sometimes running the debugger step by step brought up the custom contextmenu. So I assume it has something to do with a race condition between the eventListener and Mithrils draw system.

Has anybody experience with custom contextmenus and could provide me some examples?

Thanks a lot, Stefan

EDIT: As to Barneys comment regarding m.startComputation() I changed the code to the following:

var table = m("table", ctrl.sorts(ctrl.items()), [
m("tr", [ m("th", {
    oncontextmenu : ctrl.onContextMenu(ctrl.contextMenuId, "context-menu context-menu-bkg", "hide" )
}, "S"), m("th[data-sort-by=pName]", "Name"),
m("th[data-sort-by=pSize]", "Size"), 
m("th[data-sort-by=pPath]", "Path"), 
m("th[data-sort-by=pMedia]", "Media") ]), 
ctrl.items().map(function(item, idx) {
    return m("tr", ctrl.initRow(item, idx), {
        key : item.guid
    }, [ m("input[type=checkbox]", {
        id : item.guid,
        checked : ctrl.isSelected(item.guid),
        onclick : function(e) {
            console.log("1000")
            ctrl.setSelected(this.id);
        }
    }), m("td", item.pName), m("td", utils.numberWithDots(item.pSize)), 
    m("td", item.pPath), m("td", item.pMedia) ])
}) ])

And the implementing function onContextMenu:

// open a context menu
// @elementId   the id of the element which resembles the context menu.
//              Usually this is a div.
// @classShow   the name of the css class for showing the menu
// @classHide   the name of the css class for hiding the menu
vmc.onContextMenu = function(elementId, classShow, classHide) {
    var callback = function(e) {
        console.log("3010" + this)
        var contextmenudiv = document.getElementById(elementId);
        contextmenudiv.className = classHide;
        document.removeEventListener("click", callback, false);
        document.removeEventListener("keydown", callback, false);
    }
    return function(e) {
        console.log("3000" + this)
        var contextmenudiv = document.getElementById(elementId);
        // Prevent the browser from opening the default context menu
        e.preventDefault();
        var coords = utils.getCoords(e);
        contextmenudiv.style.left = coords[0] + "px";
        contextmenudiv.style.top = coords[1] + "px";
        // Display it
        contextmenudiv.className = classShow;
        // When you click somewhere else, hide it
        document.addEventListener("click", callback, false);
        document.addEventListener("keydown", callback, false);
    }
};

Now this works without problems. Barney, if you could be so kind to confirm this as a viable way, I'll post it as answer.

Thanks, Stefan


Solution

  • This is a working solution:

    var table = m("table", ctrl.sorts(ctrl.items()), [
        m("tr", [ m("th", {
            oncontextmenu : ctrl.onContextMenu(ctrl.contextMenuId, "context-menu  context-menu-bkg", "hide" )
        }, "S"), m("th[data-sort-by=pName]", "Name"),
        m("th[data-sort-by=pSize]", "Size"), 
        m("th[data-sort-by=pPath]", "Path"), 
        m("th[data-sort-by=pMedia]", "Media") ]), 
        ctrl.items().map(function(item, idx) {
            return m("tr", ctrl.initRow(item, idx), {
                key : item.guid
        }, [ m("input[type=checkbox]", {
                id : item.guid,
                checked : ctrl.isSelected(item.guid),
                onclick : function(e) {
                    console.log("1000")
                ctrl.setSelected(this.id);
            }
        }), m("td", item.pName), m("td", utils.numberWithDots(item.pSize)), 
        m("td", item.pPath), m("td", item.pMedia) ])
    }) ])
    

    And the implementing function onContextMenu:

    // open a context menu
    // @elementId   the id of the element which resembles the context menu.
    //              Usually this is a div.
    // @classShow   the name of the css class for showing the menu
    // @classHide   the name of the css class for hiding the menu
    vmc.onContextMenu = function(elementId, classShow, classHide) {
        var callback = function(e) {
            console.log("3010" + this)
            var contextmenudiv = document.getElementById(elementId);
            contextmenudiv.className = classHide;
            document.removeEventListener("click", callback, false);
            document.removeEventListener("keydown", callback, false);
        }
        return function(e) {
            console.log("3000" + this)
            var contextmenudiv = document.getElementById(elementId);
            // Prevent the browser from opening the default context menu
            e.preventDefault();
            var coords = utils.getCoords(e);
            contextmenudiv.style.left = coords[0] + "px";
            contextmenudiv.style.top = coords[1] + "px";
            // Display it
            contextmenudiv.className = classShow;
            // When you click somewhere else, hide it
            document.addEventListener("click", callback, false);
            document.addEventListener("keydown", callback, false);
        }
    };
    

    Now the context menu is outside of mithrils render cycle and there are no race conditions anymore. Also the click event for hiding the menu is attached to the document and does not get into conflict with a click handler attached by mithril.

    Tested with Firefox 38.01