Sunday, March 27, 2011

Silverlight 4 file drag-and-drop on Firefox on Mac

Jonathan Rupp

tl;dr: The code in this post supports file drag-and-drop in Silverlight 4 on Firefox on Mac OS X (as well as Safari on OS X, though that's with JS already available on the Internet).  Code download is at the end of the post.

Silverlight is great for building rich cross-browser web applications, especially for internally-facing applications where it’s ok to require the installation of the Silverlight runtime.  Why?  Because it works pretty much the same on all the supported environments and browsers.  I don’t have to worry about the wacky HTML differences that are IE 6 and how they differ from IE 9, Firefox, Chrome, and Safari.  I can do real transparency and rounded corners without having to cut 500 images and overlay DIVs in a slick way to achieve the visual effect the designer is looking for (and then test it on every browser/platform combination).  I can use a real strongly-typed language on the client, get full data binding support, great Intellisense from Visual Studio, and oh yeah - WCF 4 RIA Services is *really nice*.  All great things.

Sometimes, the great things just don’t quite work out like we hope.  Case in point: Silverlight 4 file drag-and-drop.  On all the Windows browsers, “it just works”.  No hassles at all.  On a Mac?  Well… there’s stuff you can do to make it work on Safari (http://forums.silverlight.net/forums/p/180989/412444.aspx), but we weren't able to find anything that would work on Firefox or Chrome.  A few weeks before release, my project manager comes to me and says – “the client says they really need file drag-and-drop to work on Firefox on a Mac – can you make it happen?”  At first, I said “no,” but then I started thinking about it a bit further.  Way back in the beginning of the project, we were discussing the technology we would to build this (and yes, this was before the PDC 2010 keynote about the future of Silverlight and HTML5 support at Microsoft), and we had marked down that one benefit of HTML5/JS/CSS was that we could support file drag-and-drop on all the Mac browsers, not just Safari (as Chrome/Firefox on the Mac are HTML5 browsers that support that).  And I got to thinking – Firefox on the Mac (newest versions) supports HTML5 drag-and-drop via Javascript – why can’t I use that to make this happen?

I started looking into this approach a bit, but didn’t hold out a lot of hope.  If I could make it work, surely Microsoft would have done the same thing and just included it in the runtime (or someone else would have tried it and posted it on the Internet).  My first attempt was to just register for the events directly from Silverlight and go, but that didn’t work.  The ‘drop’ event always had a null event.dataTransfer property.  After a bit more experimentation, I discovered that if I registered for the event in Javascript, I’d get the file data I needed – I just needed to have my Javascript code fetch the data and forward it along to Silverlight for me.  And thus this project was born.

Here’s the heart of the magic – it’s a Javascript function that is passed the Silverlight element to register for the drop event on, and a callback to call with an array of objects representing the files that were dropped:

  1. // register the event listener to decode the dropped data and pass it along to Silverlight - only used by FF on the Mac (eventually may be used by Chrome on Mac too)
  2. function RegisterDropHelper(dropArea, callback) {
  3.     dropArea.addEventListener("drop", function (evt) {
  4.         var files = evt.dataTransfer.files;
  5.         var tgtFiles = [];
  6.         for (var i = 0; i < files.length; i++) {
  7.             var file = files[i];
  8.             if (null != file.getAsDataURL) {
  9.                 // Chrome doesn't support getAsDataUrl
  10.                 var obj = { fileName: file.fileName, size: file.size, dataUrl: file.getAsDataURL() };
  11.                 tgtFiles.push(obj);
  12.             }
  13.         }
  14.  
  15.         if (null != callback) {
  16.             if (callback(tgtFiles)) {
  17.                 evt.stopPropagation();
  18.             }
  19.         }
  20.  
  21.     }, false);
  22. }

(the above code will only work with Firefox, as Chrome/Safari don’t support .getDataAsUrl on the file object.  Getting it working there should just require using the HTML5 File API, but I haven’t looked into it – see http://www.thebuzzmedia.com/html5-drag-and-drop-and-file-api-tutorial/ if you’re interested, and send me a link (or the code) if you get it figured out)

Then the only problem is routing the data into the normal drag-and-drop events.  Unfortunately, I didn’t have a lot of luck on that front.  However, I already had a behavior that translated the drag-and-drop events into command calls on my ViewModels, so I just took the data from the FF/Mac drag-and-drop helper and routed it directly to the behavior, where the ViewModel command was called just like the normal drag-and-drop events.  I turn the data coming from Javascript and the data coming from the normal drag-and-drop into instances of an object implementing the IFileInfo interface I created, change my commands to expect that instead of System.IO.FileInfo, and everything just kinda worked:

  1. public interface IFileInfo
  2. {
  3.     Stream OpenRead();
  4.     string Name { get; }
  5.     long Length { get; }
  6. }

So, how do I apply this to my project you ask?

Well, first download the code at the end of this post.  You’ll need to pull the “DragDropHelper.js” file into your website project and reference it from your ASPX page.  You’ll also need to give your Silverlight <object/> tag an id (I used slControl – if you use a different name, just search for references to that and replace them).  If you want to support Safari on Mac, you’ll need to add a call to the Javascript function “HookDropEvents();” to your HTML *after* the Silverlight object tag (or in a document.ready call if you’re using JQuery) - this is using the code from http://forums.silverlight.net/forums/p/180989/412444.aspx.  That’s it for the HTML/Javascript side. 

Now for Silverlight.  You’ll need these .cs files from the ‘SLDragDrop’ project:

  • BrowserConsole.cs (wrappers around the browser’s console.log support so you can call it from Silverlight.  Very useful if you aren’t using logging of some other kind, like Clog)
  • FileDragAndDrop.cs (the behavior and attached properties to use with it – the behavior implementation extends Prism’s commanding infrastructure.  If you’re not using Prism, use the base class from your MVVM implementation of choice, or just do that wiring yourself)
  • FileDragAndDropHelper.cs (the class that wires the Javascript above and the behavior in FileDragAndDrop.cs together)
  • IFileInfo.cs (the IFileInfo interface and some extensions to work with it)
  • Utilities.cs (works around an issue with string.IndexOf on Macs that Chris Domino discovered)

The other files are part of the example usage of it (App.xaml, ImagePreviewConverter.cs, MainPage.Xaml, and ViewModel.cs).

So, you’ve pulled in that Javascript, added in the C# code, now what?  Now you add the needed parts to your ViewModel and View to wire it all together.  First, I’m assuming you have a ViewModel something like this:

  1. public class ViewModel : DependencyObject
  2. {
  3.     public ViewModel()
  4.     {
  5.         DropCommand = new DelegateCommand<IFileInfo[]>(OnDrop);
  6.         Files = new ObservableCollection<IFileInfo>();
  7.     }
  8.     public static readonly DependencyProperty FilesProperty = DependencyProperty.Register("Files", typeof(ObservableCollection<IFileInfo>), typeof(ViewModel), new PropertyMetadata(null));
  9.     public ObservableCollection<IFileInfo> Files { get { return (ObservableCollection<IFileInfo>)GetValue(FilesProperty); } set { SetValue(FilesProperty, value); } }
  10.  
  11.     public static readonly DependencyProperty DropCommandProperty = DependencyProperty.Register("DropCommand", typeof(ICommand), typeof(ViewModel), new PropertyMetadata(null));
  12.     public ICommand DropCommand { get { return (ICommand)GetValue(DropCommandProperty); } set { SetValue(DropCommandProperty, value); } }
  13.  
  14.     private void OnDrop(IFileInfo[] files)
  15.     {
  16.         if (null == files)
  17.             return;
  18.         files.ToList().ForEach(f => Files.Add(f));
  19.     }
  20. }

I’m guessing your ViewModel will have more interesting logic in OnDrop, but this will service for now.  In your View, define a prefix for the XAML namespace your FileDragAndDrop class is in, like this:

  1. <UserControl x:Class="SLDragDrop.MainPage"
  2.     xmlns:int="clr-namespace:SLDragDrop"

Then, define the control you want to be drag-and-dropable:

  1. <ContentControl
  2.     int:FileDragAndDrop.Command="{Binding Path=DropCommand}"
  3.     int:FileDragAndDrop.DragEnterVisualState="Dragging"
  4.     int:FileDragAndDrop.DragLeaveVisualState="NoDrag"
  5.     Height="200" Width="200"
  6.     >

FileDragAndDrop.Command is the only thing required to make this work, but we found working with users, they better understood where to drop files if the UI changed in some way when they hovered over the element with a file – the DragEnterVisualState and DragLeaveVisualState properties specify a VisualState to transition to when the drag event starts and ends.  In the sample project, I use it to light up the background of the ContentControl during the drag operation.

The last piece is to call FileDragDropHelper.Enable with the root visual of your application (it’ll use this to attach the needed event handlers).  I do this in the Loaded event on my main visual:

  1. public MainPage()
  2. {
  3.     InitializeComponent();
  4.     this.DataContext = new ViewModel();
  5.     this.Loaded += new RoutedEventHandler(MainPage_Loaded);
  6. }
  7.  
  8. void MainPage_Loaded(object sender, RoutedEventArgs e)
  9. {
  10.     FileDragDropHelper.Enable(this);
  11. }

And there you have it – a Silverlight 4 FileDragAndDrop behavior via an attached command that actually works on Firefox on a Mac.  (and with a little more tweaking, would probably work on Chrome on a Mac too).

Loading Next Article