Search code examples
c#richtextboxsilverlight-5.0copy-pastecomponentone

C1RichTextBox with custom copy/paste behavior


When using C1RichTextBox in Silverlight 5 with IE 10, I am facing two major issues:

  1. During a clipboard paste operation, how can I detect if the content was copied from another C1RichTextBox in my Silverlight application or from an external application? From external applications, only text should be pasted without formatting.
  2. Copy/Pasting large inline images from one C1RichTextBox to another does not work. The <img> elements have the image content stored in their data URL. If the image becomes too large (approx 1MB), the src attribute is dropped when copied to the clipboard.

The solution should:

  • Not have side-effects on the global clipboard or on the editing behavior of the C1RichTextBox.
  • Be robust against changes to the C1RichTextBox implementation.
  • Not have to modify/parse/analyze the HTML document in the clipboard.

Solution

  • It took me a while to figure all this (an a lot more...) out and I am happy to share with anyone who has to deal with these issues.

    I am using a derived class to solve the issues

    public class C1RichTextBoxExt : C1RichTextBox
    {
    

    1. Pasting from external application with text-only

    The solution is simple in theory: Get a hold of the HTML after text from within the RichTextBox was copied/cut to the clipboard. When pasting, compare the current HTML in the clipboard with what was last copied. Because the clipboard in ComponentOne is global, the content changes if a Copy/Cut was done in another application and thus the HTML will be different.

    To remember the last copied HTML, we use a static member inside C1RichTextBoxExt:

    private static string _clipboardHtml;
    

    The bad news is: The C1RichTextBox.ClipboardCopy() etc. methods are not virtual. The good news is: The keyboard shortcuts for Copy/Cut/Paste which call these methods can be disabled, e.g. in the constructor:

    RemoveShortcut(ModifierKeys.Control, Key.C);
    RemoveShortcut(ModifierKeys.Control, Key.Insert);
    RemoveShortcut(ModifierKeys.Control, Key.V);
    RemoveShortcut(ModifierKeys.Shift  , Key.Insert);
    RemoveShortcut(ModifierKeys.Control, Key.X);
    RemoveShortcut(ModifierKeys.Shift  , Key.Delete);
    

    Now that the methods C1RichTextBox.ClipboardCopy() etc. are no longer called we can wire up our own version by overriding OnKeyDown:

    protected override void OnKeyDown(KeyEventArgs e)
    {
        if      ((Keyboard.Modifiers == ModifierKeys.Control) && (e.Key == Key.C))      { ClipboardCopy();  }
        else if ((Keyboard.Modifiers == ModifierKeys.Control) && (e.Key == Key.Insert)) { ClipboardCopy();  }
        else if ((Keyboard.Modifiers == ModifierKeys.Control) && (e.Key == Key.V))      { ClipboardPaste(); }
        else if ((Keyboard.Modifiers == ModifierKeys.Control) && (e.Key == Key.X))      { ClipboardCut();   }
        else if ((Keyboard.Modifiers == ModifierKeys.Shift)   && (e.Key == Key.Insert)) { ClipboardPaste(); } 
        else if ((Keyboard.Modifiers == ModifierKeys.Shift)   && (e.Key == Key.Delete)) { ClipboardCut();   } 
        else
        {
            // default behaviour
            base.OnKeyDown(e);
            return;
        }
    
        e.Handled = true; // base class should not fire KeyDown event
    }
    

    To not accidentally call the base class methods, I am overwriting them (see below, using new modifier). The ClipboardCopy() method just calls the base class and afterwards stores the clipboard HTML. A small pitfall here was to use Dispatcher.BeginInvoke() since the C1RichTextBox.ClipboardCopy() stores the selected text in the clipboard inside a Dispatcher.BeginInvoke() invocation. So the content will only be available after the dispatcher had a chance to run the action provided by C1RichTextBox.

    new public void ClipboardCopy()
    {
        base.ClipboardCopy();
    
        Dispatcher.BeginInvoke(() =>
        {
            _clipboardHtml = C1.Silverlight.Clipboard.GetHtmlData();
        });
    }
    

    The ClipboardCut method is very similar:

    new public void ClipboardCut()
    {
        base.ClipboardCut();
    
        Dispatcher.BeginInvoke(() =>
        {
            _clipboardHtml = C1.Silverlight.Clipboard.GetHtmlData();
        });
    }
    

    The ClipboardPaste method can now detect if pasting external data. Pasting text only is no so straightforward. I came up with the idea to replace the current clipboard content with the text-only representation of the clipboard. After pasting is done, the clipboard should be restored so the content can be pasted again in other applications. This also has to be done within a Dispatcher.BeginInvoke() since the base class method C1RichTextBox.ClipboardPaste() performs the paste operation in a delayed action as well.

    new public void ClipboardPaste()
    {
        // If the text in the global clipboard matches the text stored in _clipboardText it is 
        // assumed that the HTML in the C1 clipboard is still valid 
        // (no other Copy was made by the user).
        string current = C1.Silverlight.Clipboard.GetHtmlData();
    
        if(current == _clipboardHtml)
        {
            // text is the same -> Let base class paste HTML
            base.ClipboardPaste();
        }
        else
        {
            // let base class paste text only
            string text = C1.Silverlight.Clipboard.GetTextData();
            C1.Silverlight.Clipboard.SetData(text);
    
            base.ClipboardPaste(); 
    
            Dispatcher.BeginInvoke(() =>
            {
                // restore clipboard
                C1.Silverlight.Clipboard.SetData(current);
            });
        }
    }
    

    2. Copy/Pasting large inline images

    The idea here is similar: Remember the images when copied, put them back in during paste.

    So first we need to store where which image is in the document:

    private static List<C1TextElement> _clipboardImages;
    private static int _imageCounter;
    

    (The use of _imageCounter will be explained later...)

    Then, before Copy/Cut is executed, we search for all images:

    new public void ClipboardCopy()
    {
        _clipboardImages = FindImages(Selection);
    
        base.ClipboardCopy();
        // ... as posted above
    }
    

    and similar:

    new public void ClipboardCut()
    {
        _clipboardImages = FindImages(Selection);
    
        base.ClipboardCut();
        // ... as posted above
    }
    

    The methods to find the images are:

    private List<BitmapImage> FindImages(C1TextRange selection = null)
    {
        var result = new List<BitmapImage>();
        if (selection == null)
        {
            // Document Contains all elements at the document level.
            foreach (C1TextElement elem in Document)
            {
                FindImagesRecursive(elem, result);
            }
        }
        else
        {
            // Selection contains all (selected) elements -> no need to search recursively
            foreach (C1TextElement elem in selection.ContainedElements)
            {
                if (elem is C1InlineUIContainer)
                {
                    FindImage(elem as C1InlineUIContainer, result);
                }
            }
        }
    
        return result;
    }
    
    private void FindImagesRecursive(C1TextElement elem, List<BitmapImage> list)
    {
        if (elem is C1Paragraph)
        {
            var para = (C1Paragraph)elem;
            foreach (C1Inline inl in para.Inlines)
            {
                FindImagesRecursive(inl, list);
            }
        }
        else if (elem is C1Span)
        {
            var span = (C1Span)elem;
            foreach (C1Inline child in span.Inlines)
            {
                FindImagesRecursive(child, list);
            }
        }
        else if (elem is C1InlineUIContainer)
        {
            FindImage(elem as C1InlineUIContainer, list);
        }
    }
    
    private void FindImage(C1InlineUIContainer container, List<BitmapImage> list)
    {
        if (container.Content is BitmapImage)
        {
            list.Add(container.Content as BitmapImage);
        }
    }
    

    I won't go into details about the above methods, they are pretty straightforward if you analyze the structure of C1RichTextBox.Document.

    Now, how do we restore the images? The best I found is to use the ConvertingHtmlNode event of the C1RichTextBox.HtmlFilter. This event is fired every time a HTML node is converted into a C1TextElement. We subscribe to it in the constructor:

    HtmlFilter.ConvertingHtmlNode += new EventHandler<ConvertingHtmlNodeEventArgs>(HtmlFilter_ConvertingHtmlNode);
    

    and implement it like this:

    void HtmlFilter_ConvertingHtmlNode(object sender, ConvertingHtmlNodeEventArgs e)
    {
        if (e.HtmlNode is C1HtmlElement)
        {
            var elem = e.HtmlNode as C1HtmlElement;
    
            if (elem.Name.ToLower() == "img" && _clipboardImages != null && _clipboardImages.Count > _imageCounter)
            {
                if (!elem.Attributes.ContainsKey("src")) // key comparison is not case sensitive
                {
                    e.Parent.Children.Add(_clipboardImages[_imageCounter].Clone());
                    e.Handled = true;
                }
                _imageCounter++;
            }
        }
    }
    

    So for each HTML element node with the name "img" we check if the "src" attribute is missing. If so, we add the next stored image instead and tell the event source that the event is now handled (for this HTML node) by setting e.Handled = true; Which image is the "next" image is determined by the _imageCounter field which is incremented for each visited "img" element.

    The _imageCounter field must be reset when ClipboardPaste() is invoked, so we do:

    new public void ClipboardPaste()
    {
        _imageCounter = 0;
    
        string current = C1.Silverlight.Clipboard.GetHtmlData();
        // ... as posted above
    }
    

    Conclusion

    If you copy/paste (no pun intended...) all code blocks posted above together, you should end up with a solution which has no side-effects (at least none known to the author as of today), is robust against changes and does almost no HTML processing.