Friday, April 29, 2011

Flex: Custom context menu

Background

I never really liked the context menus supplied by the flash player that you are bound to use when developing flex applications. Mostly because of the non-customizable look and feel and the fact that your application specific menu items will be in the same list as the built in items (you can't hide all of them). Since we are using context menus in many places in our applications I have been looking at the following solution for a while to replace the built in menus:

http://code.google.com/p/custom-context-menu/

This solution uses javascript to capture the right click event over the SWF to prevent the flash player from opening its built-in context menu. The event is then passed on to a function that communicates with Flash over the ExternalInterface. The function called in action script is then free to do whatever it likes to act upon the performed right click.

The drawback of the solution is that it relies on using wmode='opaque' for the flash object and this does not work reliably across different browsers and operating systems.

Anyway, when running into the following bug with the latest Safari browser (5.0) on Mac:

http://bugs.adobe.com/jira/browse/FP-4825

I decided to go ahead and implement custom context menus, at least for our Mac users.

Implementation

The implementation relies on the solution mentioned above for handling the right click events and passing them on to the Flex application. An action script function then traverses the components currently located below the mouse pointer until it finds one that has a context menu defined. The found ContextMenu object then gets wrapped in a CustomContextMenu wrapper that uses the original context menu to create and show a regular flex menu.

The CustomContextMenu wrapper also makes sure that the enabled and visible statuses of the context menu items are also reflected in the items of the generated menu. Last but not least the wrapper makes sure that any user actions on the generated menu are dispatched as events on the original context menu so that the behavior of the original context menu is preserved in the new menu.

That's it. Enough talking. Let's look at the code, feel free to re-use it in any way you deem fit:

This is the code for the CustomContextMenu wrapper:

CustomContextMenu.as

package com.flexceptional.components
{
    import flash.events.ContextMenuEvent;
    import flash.ui.ContextMenu;
    import flash.ui.ContextMenuItem;
    
    import mx.controls.Menu;
    import mx.events.MenuEvent;
    
    public class CustomContextMenu
    {
        private static var SEPARATOR:Object = {type: "separator"};
        
        private var _menu:Menu;
        private var _contextMenu:ContextMenu;
        
        public function CustomContextMenu(contextMenu:ContextMenu)
        {   
            _contextMenu = contextMenu;
            
            // Create menu
            _menu = Menu.createMenu(null, null);
            
            // Add event listeners
            _menu.addEventListener(MenuEvent.MENU_SHOW, menuShowHandler);
            _menu.addEventListener(MenuEvent.ITEM_CLICK, itemClickHandler);
            
            // Set properties
            _menu.setStyle("openDuration", 0);
            _menu.variableRowHeight = true;
            _menu.id = "contextMenu";
            
            // Create custom menu items
            var menuItems:Array = [];
            for each (var item:ContextMenuItem in contextMenu.customItems)
            {
                if (item.separatorBefore)
                {
                    menuItems.push(CustomContextMenu.SEPARATOR);
                }
                menuItems.push(new CustomContextMenuItem(item));
            }
            _menu.dataProvider = menuItems;
        }
        
        private function menuShowHandler(event:MenuEvent):void
        {
            _contextMenu.dispatchEvent(new ContextMenuEvent(ContextMenuEvent.MENU_SELECT));
        }
        
        private function itemClickHandler(event:MenuEvent):void
        {
            event.item.dispatchEvent(new ContextMenuEvent(ContextMenuEvent.MENU_ITEM_SELECT));
        }
        
        public function show(xShow:Object = null, yShow:Object = null):void
        {
            _menu.show(xShow, yShow);
        }
        
        public function hide():void
        {
            _menu.hide();
        }
    }
}

import flash.events.ContextMenuEvent;
import flash.events.Event;
import flash.events.EventDispatcher;
import flash.ui.ContextMenuItem;

// ContextMenuItem wrapper
class CustomContextMenuItem extends EventDispatcher
{   
    private var _contextMenuItem:ContextMenuItem;
    
    public function CustomContextMenuItem(contextMenuItem:ContextMenuItem)
    {
        _contextMenuItem = contextMenuItem;
        
        // Dispatch event on the proxied ContextMenuItem
        addEventListener(ContextMenuEvent.MENU_ITEM_SELECT, function(event:Event):void
        {
            _contextMenuItem.dispatchEvent(event);
        });
    }
    
    public function get label():String
    {
        return _contextMenuItem.caption;
    }
    
    public function get enabled():Boolean
    {
        return _contextMenuItem.enabled;
    }
    
    public function get visible():Boolean
    {
        return _contextMenuItem.visible;
    }
}

This is the function invoked by the java script, place it in your main application (the optional x and y arguments can be used for automated testing):

private var customContextMenu:CustomContextMenu;

private function rightClickHandler(clickedX:Number = NaN, clickedY:Number = NaN):void {
    // Close previous context menu
    if (customContextMenu)
    {
        customContextMenu.hide();
    }
    
    clickedX = isNaN(clickedX) ? mouseX : clickedX;
    clickedY = isNaN(clickedY) ? mouseY : clickedY;
    
    // Get objects under clicked point
    var objects:Array = systemManager.getObjectsUnderPoint(new Point(clickedX, clickedY));
    if (objects.length > 0)
    {
        // Get the top most object clicked
        var object:Object = objects[objects.length-1];
        
        // Check if any of the parent objects have
        // a context menu defined
        while (object)
        {
            if (object.hasOwnProperty("contextMenu") && object.contextMenu)
            {
                customContextMenu = new CustomContextMenu(object.contextMenu);
                customContextMenu.show(clickedX, clickedY);
                break;
            }
            object = object.parent;
        }
    }
}

The code snippet for making the above function available through the ExternalInterface, place it in the initializeHandler of your main application file:

// Enable custom right click functionality
ExternalInterface.addCallback("rightClick", rightClickHandler);

We use the following javascript function to initialize the right click handling only for Mac users running Safari 5, place it in your html template and call it on the onload event on the body tag:

function initRightClick() {
    if (BrowserDetect.OS == "Mac" &&
        BrowserDetect.browser == "Safari" &&
        BrowserDetect.version == "5") {
        RightClick.init();
    }
}

The BrowserDetect object referenced in the above function comes from a file created using the code posted on this page:

http://www.quirksmode.org/js/detect.html

So, big post, feel free to comment on it if you have any questions or doubts. The solution provides a nice workaround for the Mac Safari 5 context menu bug and since our context menus can now be invoked by calling a function from javascript it also allows for us to do automated testing of our context menus using our integration test setup. More on testing in a later post.

2 comments:

  1. Just wondering if there a way to do the reverse in Safari. To stop a website from being able to override the browsers contextual menu?

    ReplyDelete
  2. Hi,

    How to show contexmenu for right click after removing.

    ReplyDelete