Monday, August 31, 2009

A Pattern For Control-Specific Context Menu on Right-Click in Silverlight

There are a lot of good examples online, about how to make Silverlight applications respond to mouse right-clicks. So, I am not going to rehash it in detail here. Instead, I will attempt to describe a pattern that I found useful, when I implemented right-click context menus for controls on a page. The requirements for the functionality are:

1. There is a unified and consistent way to display context menu.
2. The control provides the data for the display (image / text / data).
3. The control is responsible for responding to the user selection.

Here is a demo application that uses the pattern I describe. Just as before, there is something about the script on the silverlight live site that doesn't make it work right for firefox. Works fine in IE, and when hosted on another site, on Firefox too. Since the app works ok when code is compiled and run on any browser, I am not going to sweat it.



Link to app here: http://silverlight.services.live.com/invoke/105917/Control-Specific%20Right-click%20Context%20Menu%20Demo/iframe.html

The codebase can be downloaded here: http://www.filefactory.com/file/ah498ca/n/ContextMenuDemo_zip


The main component of the functionality is the right-click interceptor class, which interacts with the DOM and intercepts the right mouse down, mouseclick, oncontextmenu events. Make sure your app is set as "windowless". All these aspects, it shares with other examples online. We will come to some extra functionality of this class later. The RootVisual of the app has an instance of this class.

  1         private void OnMouseDown(object sender, HtmlEventArgs e)
  2         {
  3             if (e.MouseButton == MouseButtons.Right)
  4             {
  5                 foreach (FrameworkElement ei in _ElementsToIntercept)
  6                 {
  7                     if (IsPointWithinBounds(new Point(e.OffsetX, e.OffsetY), ei))
  8                     {
  9                         e.PreventDefault();
 10                         if (MouseRightButtonDown != null)
 11                             MouseRightButtonDown(this, e);
 12                         RightClickedControl = ei;
 13                         _IsButtonDown = true;
 14                         break;
 15                     }
 16                 }
 17             }
 18         }
 19         private void OnMouseUp(object sender, HtmlEventArgs e)
 20         {
 21             if (e.MouseButton == MouseButtons.Right && _IsButtonDown)
 22             {
 23                 e.PreventDefault();
 24                 _IsButtonDown = false;
 25                 if (MouseRightButtonUp != null)
 26                     MouseRightButtonUp(this, e);
 27             }
 28         }
 29 
 30         private void OnContextMenu(object sender, HtmlEventArgs e)
 31         {
 32             foreach (FrameworkElement ei in _ElementsToIntercept)
 33             {
 34                 if (IsPointWithinBounds(new Point(e.OffsetX, e.OffsetY), ei))
 35                 {
 36                     e.PreventDefault();
 37                     break;
 38                 }
 39             }
 40         }
 41 


The second component is the "ISupportCOntextMenu" interface.

  1     public interface ISupportContextMenu
  2     {
  3         void HandleContextMenuClick(object sender, ContextMenuClickEventArgs e);
  4         List<ContextMenuItemInfo> GetContextMenuContent(HtmlEventArgs e);
  5     }

Any control that needs a context menu needs to implement this interface, and the control needs to be registered with the rootvisual. Thre registration process brings us to the extra stuff in the interceptor. Interceptor maintains a list of references to ISupportContextMenu. Registration just adds to this list. When an interceptable event happens, the interceptor checks to see if it happened within the bounds of any of the items in the list (Big part of the whole thing).

  1         public static bool IsPointWithinBounds(Point p, FrameworkElement element)
  2         {
  3             try
  4             {
  5                 GeneralTransform gt = element.TransformToVisual(Application.Current.RootVisual as FrameworkElement);
  6                 Point cp = gt.Transform(new Point(0, 0));
  7 
  8                 if (p.X <= cp.X + element.ActualWidth && p.X >= cp.X && p.Y <= cp.Y + element.ActualHeight && p.Y >= cp.Y)
  9                     return true;
 10                 else
 11                     return false;
 12             }
 13             //if the control has been cleared from parent 'transformtovisual' will throw an error.
 14             //In that case return false
 15             catch { return false; }
 16         }

If it did, then it asks the control to provide data for the context menu (GetContextMenuContent()).

  1 //From instantiating control:
  2 
  3         public Home()
  4         {
  5             InitializeComponent();
  6             ((MainPage)App.Current.RootVisual).RegisterControlWithContextMenu(Ctx1);
  7             ((MainPage)App.Current.RootVisual).RegisterControlWithContextMenu(Ctx2);
  8         }
  9 
 10 //From RootVisual:
 11 
 12         public void RegisterControlWithContextMenu(ISupportContextMenu control)
 13         {
 14             _RightClickIntrcpt.AddElementToIntercept(control);
 15         }
 16 
 17 //From Interceptor:
 18 
 19         public void AddElementToIntercept(ISupportContextMenu fe)
 20         {
 21             if (!_ElementsToIntercept.Contains(fe))
 22                 _ElementsToIntercept.Add(fe);
 23         }


This brings us to the third component: ContextMenuItemInfo.

  1     public class ContextMenuItemInfo
  2     {
  3         public Image Image { get; set; }
  4         public string Command { get; set; }
  5         public Object Tag { get; set; }
  6     }
You can provide a list of items, each of which contains an image (optional), text, and any kind of data that makes sense in the context (optional) to the interceptor. The text and data tag is passed back to the control as a KeyValuePair if user click on it.

The third component is a popup control that holds a list box. This is in the root visual. The RootVisual responds to an event raised by the interceptor and takes the  List of ContextMenuItemInfo, populates the listbox, moves the popup to where the mouse is, and displays it. There is a DispatcherTimer and MouseEnter/MouseLeave events that make sure the popup is displayed and hidden in a graceful manner. In the RootVisual:

  1         private void InitializeContextMenuIntercept()
  2         {
  3             InitializeContextMenuTimer();
  4             _RightClickIntrcpt = new ContextMenuInterceptor();
  5             _RightClickIntrcpt.MouseRightButtonUp += new EventHandler<System.Windows.Browser.HtmlEventArgs>(_RightClickIntrcpt_MouseRightButtonUp);
  6         }
  7 
  8         private void InitializeContextMenuTimer()
  9         {
 10             _ContextMenuTimer = new DispatcherTimer();
 11             _ContextMenuTimer.Interval = TimeSpan.FromSeconds(2);
 12             _ContextMenuTimer.Tick += (s, e) => ContextMenu_MouseLeave(null, null);
 13         }
 14 
 15         void _RightClickIntrcpt_MouseRightButtonUp(object sender, System.Windows.Browser.HtmlEventArgs e)
 16         {
 17             ISupportContextMenu cntrl = _RightClickIntrcpt.RightClickedControl as ISupportContextMenu;
 18             if (cntrl != null)
 19             {
 20                 List<ContextMenuItemInfo> mnuContent = cntrl.GetContextMenuContent(e);
 21                 if (mnuContent != null && mnuContent.Count > 0)
 22                 {
 23                     ContextMenuListBox.ItemsSource = from mc in mnuContent
 24                                                      select new ListBoxItem
 25                                                      {
 26                                                          Content = GetContextMenuItemVisual(mc.Command, mc.Image),
 27                                                          Tag = new KeyValuePair<string, object>(mc.Command, mc.Tag)
 28                                                      };
 29                     ContextMenuListBox.MouseLeftButtonUp += ContextMenuListBox_MouseLeftButtonUp;
 30                     ContextMenuPopup.Margin = new Thickness(e.OffsetX, e.OffsetY, 0, 0);
 31                     ContextMenuPopup.IsOpen = true;
 32                     _ContextMenuTimer.Start();
 33                 }
 34             }
 35         }
 36 
 37         FrameworkElement GetContextMenuItemVisual(string command, Image image)
 38         {
 39             StackPanel ret = new StackPanel
 40             {
 41                 Orientation = Orientation.Horizontal,
 42                 Margin = new Thickness(2),
 43             };
 44             if (image != null)
 45             {
 46                 image.Margin = new Thickness(0, 0, 4, 0);
 47                 ret.Children.Add(image);
 48             }
 49             ret.Children.Add(new TextBlock { Text = command });
 50             return ret;
 51         }
 52 
 53 
 54         void ContextMenuListBox_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
 55         {
 56             if (ContextMenuListBox.SelectedItem != null)
 57                 ((ISupportContextMenu)_RightClickIntrcpt.RightClickedControl).HandleContextMenuClick(this, new ContextMenuClickEventArgs
 58                 {
 59                     ClickedItem = (KeyValuePair<string, object>)((ListBoxItem)ContextMenuListBox.SelectedItem).Tag
 60                 });
 61             ContextMenuListBox.MouseLeftButtonUp -= ContextMenuListBox_MouseLeftButtonUp;
 62             ContextMenuPopup.IsOpen = false;
 63         }
 64 
 65         void ContextMenu_MouseEnter(object sender, RoutedEventArgs e)
 66         {
 67             _ContextMenuTimer.Stop();
 68             ContextMenuPopup.IsOpen = true;
 69         }
 70         void ContextMenu_MouseLeave(object sender, RoutedEventArgs e)
 71         {
 72             ContextMenuPopup.IsOpen = false;
 73             _ContextMenuTimer.Stop();
 74         }



This ensures that a control has total say if it wants a context menu, and if so, what the content should be, and how the application should respond to user selection. I know that a lot of things are not elaborated here, but I am hoping it will make sense when you go through the code. If needed, I can augment this with some diagrams. Let me know.

3 comments:

  1. Love it,

    Doesn't work in firefox though :(

    Regards

    ReplyDelete
  2. I know, the demo doesn't work in Firefox when served from the site. It does work in FF when I compile the project locally, and when I run it on a regular IIS server. The same thing with the timeline visualizer. Have no idea why, and I can't see the configuration on the silverlight live site that hosts the demos.

    -Jose

    ReplyDelete
  3. It does work in Chrome :)

    ReplyDelete