Fast Silverlight 4 Copy and Paste Context Menu [UPDATED]

My team recently deployed an early Beta release of a project for limited review. Of all the quirks and flaws, one stood out the most: users couldn’t right-click to copy and paste. A quick look for previous work on this topic turned up some stuff but what I found was based entirely on adding the functionality to one control at a time. This would take too long for us and since we use a variety of toolkit and 3rd party controls I decided to see if I could come up with something more generic.

For my first attempt I put the code directly in MainPage.xaml.cs which is the root control for our application. This first version worked with any control that exposed a SelectedText property but didn’t work on child windows. Still, the code was mostly useful so I pulled it out of MainPage and put it in its own control. For simplicity, I just extended the ContextMenu control available in the Silverlight 4 toolkit.

In Visual Studio I added a new Silverlight Control called PopupMenu.xaml. I then edited the XAML file to switch from UserControl to toolkit:ContextMenu and likewise in the class declaration of the associated CS file. I also ripped out the boilerplate XAML.

Then I created a method based on the original proof of concept code to handle MouseRightButtonDown and -Up methods. Using reflection I examined the controls in the visual tree under the mouse click to see if any of them offer a SelectedText property. If so, I check to see if it’s writable and if the control exposes a IsReadOnly property and if it’s false. If these conditions are met, the Copy, Cut and Paste menu options are presented to the user. (Alternatively just Copy if the SelectedText property is there but is not writeable or IsReadOnly is true.)

On loading of the MainPage control, I attach event listeners to the MouseRightButtonDown and -Up events passing some information onto an instance of the PopupMenu context menu control.

public partial class MainPage : UserControl
{
   private PopupMenu popupMenu = new PopupMenu();
   ...
   ...
   public MainPage()
   {
      InitializeComponent();

      this.MouseRightButtonDown +=
         new MouseButtonEventHandler(MainPage_MouseRightButtonDown);
      this.MouseRightButtonUp +=
         new MouseButtonEventHandler(MainPage_MouseRightButtonUp);
      ...
      ...
   }

   void MainPage_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
   {
      //Prevent generic Silverlight menu from popping.
      e.Handled = true;
   }

   void MainPage_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
   {
       popupMenu.HandleClick(e, this, false);
   }

This worked great for all our views based on Page but it still didn’t work for child windows. I decided that rather than go through all our child window controls and do the same as MainPage (wire up listeners and pass the calls on to PopupMenu) I would extend ChildWindow. I created a new control just like the PopupMenu. This time I changed UserControl to ChildWindow in the xaml and associated cs. In this class, ExtendedChildWindow, I attach the event listeners to the same mouse events and route the call just like MainPage.

public partial class ExtendedChildWindow : ChildWindow
{
   public ExtendedChildWindow()
   {
       InitializeComponent();

       if (!System.ComponentModel.DesignerProperties.IsInDesignTool)
       {
           this.MouseRightButtonDown +=
              new MouseButtonEventHandler(ExtendedChildWindow_MouseRightButtonDown);
           this.MouseRightButtonUp +=
              new MouseButtonEventHandler(ExtendedChildWindow_MouseRightButtonUp);
       }
   }

   void ExtendedChildWindow_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
   {
       new PopupMenu().HandleClick(e, this, true);
   }

   void ExtendedChildWindow_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
   {
       //Prevent generic Silverlight menu from popping.
       e.Handled = true;
   }

   ~ExtendedChildWindow()
   {
      this.MouseRightButtonDown -= ExtendedChildWindow_MouseRightButtonDown;
      this.MouseRightButtonUp -= ExtendedChildWindow_MouseRightButtonUp;
   }
}

Notice this time instead of newing up one PopupMenu control and using it over and over, I’m newing it each time. This is because for some reason I haven’t spent any time figuring out, if I don’t do it this way the offset goes crazy if I copy from a Page view before going into a child window and the menu is rendered in the lower-right corner of the Silverlight application. Doing it this way just works. Maybe in a future version I’ll try switching to a static method call so it doesn’t look as shady.

After that I went through all our controls based on ChildWindow (conveniently in their own solution folder) and changed them to inherit from my:ExtendedChildWindow. A few VS2010 crashes later I had all the source files updated. I ran the project and indeed the popup menu showed in views and child windows. Except in the child window controls the popup was shown in the upper left corner of the Silverlight application but it showed correctly for page views.

To fix this I added a boolean to the PopupMenu control. When true I would adjust the horizontal and vertical offset values based on the mouse position. With that in place I now had my final render tweaks working. I don’t know if this was the best way to go about it but this is how I took a proof of concept segment of code to working status integrated with the main source branch in less than 2 days.

Here are the important bits from the PopupMenu control code.

   ...
   ...
   [Flags]
   public enum TextOperations : ushort
   {
       Copy = 1,
       Paste = 2,
       Cut = Copy | Paste
   }

   public static TextOperations GetAllowedOperations(UIElement element)
   {
       TextOperations allowedOperations = new TextOperations();

       if (null != element.GetType().GetProperty("SelectedText"))
       {
           allowedOperations |= TextOperations.Copy;
       }
       if (null != element.GetType().GetProperty("SelectedText") &&
           element.GetType().GetProperty("SelectedText").CanWrite &&
           (null == element.GetType().GetProperty("IsReadOnly") ||
              (null != element.GetType().GetProperty("IsReadOnly") &&
               !bool.Parse(element.GetType().GetProperty("IsReadOnly")
                  .GetValue(element, null).ToString()))
               )
            )
       {
           allowedOperations |= TextOperations.Paste;                
       }

       return allowedOperations;
   }

   public void HandleClick(MouseButtonEventArgs e,
                                  UIElement displayElement,
                                  bool RenderContentOffset)
   {
       Point mousePosition = e.GetSafePosition(null);
       var elements = VisualTreeHelper
          .FindElementsInHostCoordinates(mousePosition, displayElement);

       TextOperations allowedOperations;
       foreach (var element in elements)
       {
           allowedOperations = GetAllowedOperations(element);

           if (allowedOperations > 0)
           {
               MenuItem option;
               Items.Clear();

               //Add menu items to this.Items
               ...

               //Now, if we found items, let's show and break out
               if (Items.Count > 0)
               {
                   ActiveElement = element;
                   if (RenderContentOffset)
                   {
                       HorizontalOffset = mousePosition.X;
                       VerticalOffset = mousePosition.Y;
                   }
                   IsOpen = true;
                   break;
               }
           }
       }
   }

   void SetSelectedText(UIElement element, string value)
   {
       element.GetType().GetProperty("SelectedText")
          .SetValue(element, value, null);
   }

   string GetSelectedText(UIElement element)
   {
       return element.GetType().GetProperty("SelectedText")
          .GetValue(element, null).ToString();
   }

   void ContextMenuItem_Click(object sender, RoutedEventArgs e)
   {
       if (null != ActiveElement)
       {
           MenuItem item = sender as MenuItem;

           switch (item.Header.ToString())
           {
               case "Cut":
               case "Copy":
                   Clipboard.SetText(GetSelectedText(ActiveElement));

                   if (item.Header.ToString() == "Cut")
                   {
                       SetSelectedText(ActiveElement, string.Empty);
                   }

                   break;
               case "Paste":
                   SetSelectedText(ActiveElement, Clipboard.GetText());
                   break;
           }
       }

       IsOpen = false;
       ActiveElement = null;
   }

Shiny?
-Erik

[UPDATE: 19 Oct 2010 @ 1:20PM]
One of the members on the team started getting cross-thread access errors that he was able to trace to the ExtendedChildWindow for removing the mouse event listeners. In an attempt to correct this, I’ve added a new event handler in the constructor to listen to the Unloaded event and moved the code to disconnect the event listeners into that handler. Hopefully this will resolve the issue.

5 responses to “Fast Silverlight 4 Copy and Paste Context Menu [UPDATED]

  1. We started seeing some cross-thread exceptions with the ExtendedChildWindow when removing the mouse event listeners. As a result I’ve moved the code to the Unloaded event to see if that clears things up.

  2. This code is very impressive. One minor flaw is that it may allow the user to paste in excess of TextBox.MaxLength.

    • Good point. This project was a quick-turn and was very open with no specific input restrictions. But it had a lot of fields and many could easily contain the same information across records. Data input speed was a priority. Given all that I came up with the above solution to enable a quick and dirty copy/paste. It is by no means robust! Specific use cases will need a lot of testing.

      Sadly this project was terminated by the client (and the entire business process revisited and then also tossed) so I never got a chance to really delve any further.

  3. My textbox didn’t send paste text through its databinding. Solution was:
    tb.GetBindingExpression(TextBox.TextProperty).UpdateSource();

    as described here:
    http://forums.silverlight.net/t/97528.aspx

Leave a comment