(20070917 - updated to Silverlight 1.0)

Sorry for the extended absence.  I've been heads down planning for Office "14" Groove, and quite removed from all the Groove 2007 activity.  But here's something new and relevant, that I'm quite excited about.

Silverlight - if you didn't already know - is a new plugin, with quite amazing capabilities.  Vector graphics, animation, storyboards, a very rich and clean object model... and it's all .NET and XAML, and all running inside a browser.  As a platform for lightweight applications, I think it's really powerful.

Groove Forms has a relatively restricted set of user interface capabilities.  It's designed for building reasonably straightforward UI for data capture in small teams.  Forms with fields, views with columns, and a Groove workspace for distributed data storage.  But many applications would like to go beyond a simple forms-driven UI, and Groove Forms doesn't make that very easy.

Silverlight in Groove Forms, though, would be quite a nice combination.  So here it is.

This example is very simple: load the Silverlight control, interact with it in the Groove form, save some state into the Groove record, and load the state back into the control when reading or opening a record.  In this case the model itself is also very simple: a set of Path elements, drawn by the user.  It's a sketchpad.  But once you have these simple pieces, a whole set of other exciting things become possible.  Charts of data.  Interactive collaboration around a shared model...

A simple form

First let's create a very simple new form, and add four fields:

  • A text field named "Title".  This is just so we can display something in the view.
  • A static text field named "staticXaml": hidden.  We'll use this to bootstrap the Silverlight control.
  • A static text field named "staticControl".  This will contain the actual Silverlight control in the form.
  • A text field named "Data": hidden.  We'll use this to store some data that the user created.

Here's my version of this form.  The "staticControl" field has some placeholder text, just so I can find it easily.

Some scripts

We'll use the JavaScript programming capabilities of Silverlight 1.0.  The SDK contains a standard script file "Silverlight.js" to wrap all the important things about initializing the Silverlight control, and we'll use this simply by importing it into the Groove Form.  Then we'll write another script of our own.

So, in the "Form Scripts" tab of our new form, let's add two scripts:

  • Silverlight.js, imported from the SDK, and
  • SilverlightLoaders.js, a new script we'll write here.

In the "System callouts script" for this form, I have some one-line functions that call into SilverlightLoaders.js to do their work:

  • in the OnBeforeInitialize() function:  a call to form_OnBeforeInitialize();
  • in the OnAfterInitialize() function:  a call to form_OnAfterInitialize();
  • in the OnBeforeSubmitData() function:  a call to form_OnBeforeSubmitData();

Loading the Silverlight control

A Silverlight control can be embedded directly into an HTML page, or can be created at runtime using script.  We'll use the latter, since Groove Forms doesn't give you a way to define arbitrary HTML directly in the form page.  The Silverlight.js script has some functions to create the control, and embed it within the current page.

The control needs a XAML canvas definition, that will be loaded when it first starts.  (Without an initial canvas, the control really doesn't do much of anything at all).  There are two ways to supply the XAML:  from a URL, or from an element in the page.  Since Groove doesn't always need to be connected to a network, we would prefer not to rely on a web server to supply this initial content, but rather distribute it within the Forms tool istelf.

To do this, we write a XAML fragment into the "staticXaml" field declared earlier.  In the case I'm showing here, that fragment contains pretty much the whole application we're building.  Alternatively it could be just a skeleton -- the canvas -- and the rest of the model created later on the fly.

So here's our initialization script, complete with all the extra gubbins we'll see later.

// REQUIRES:
// (hidden) static text field, named "staticXaml", to hold the bootstrap xaml
// (visible) static text field, named "staticControl", where the control will be sited
// (hidden) text field, named "Data", to store the data in
 
// Globals:
var g_AgLoaded = false;           // The silverlight control has loaded
var g_AgControl = null;
var g_AgRoot = null;
var g_AgCanvas = null;
var g_AgInk = null;
 
 
// ===== Groove callbacks (form events) =====
 
// Form is being initialized for the first time
function form_OnBeforeInitialize()
{
       inkInitialize();
       createSilverlightControl();
}
 
// Form is being initialized, and Groove data is ready
function form_OnAfterInitialize()
{
       inkInitialize();
       tryLoadPaths();
}
 
function form_OnBeforeSubmitData()
{
       savePaths();
}
 
 
 
// ===== Silverlight control initialization =====
 
function createSilverlightControl()
{
       if(g_AgLoaded && g_AgControl)
       {
              // The silverlight control is already loaded and ready.
              return;
       }
 
       // Create a "script" tag containing an empty canvas.
       // This canvas will be the first item loaded into the Silverlight control.
 
       var c = '<Canvas x:Name="staticCanvas" xmlns="http://schemas.microsoft.com/client/2007" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Width="640" Height="480">' +
              '<InkPresenter x:Name="inkEl" Background="transparent" Width="500" Height="500" MouseLeftButtonDown="inkMouseDown" MouseMove="inkMouseMove" MouseLeftButtonUp="inkMouseUp" />' +
              '<Canvas x:Name="pathsCanvas" />' +
              '</Canvas>';
 
       var s = document.createElement("script");
       s.setAttribute("type", "text/xaml");
       s.setAttribute("id", "initialXaml");
       s.text = c;
       document.getElementById("staticXaml").appendChild(s);
 
       // Create the Silverlight control,
       // which will then call the "canvas_loaded" function when ready.
 
       Silverlight.createObjectEx({
              source: '#initialXaml',
              parentElement:document.getElementById("staticControl"),
              id:'AgControl1',
              properties:{
                     width:'1024',
                     height:'1024',
                     background:'transparent',
                     inplaceInstallPrompt:true,
                     isWindowless:'true',
                     framerate:'24',
                     version:'0.90.0'},
              events:{
                     onError:null,
                     onLoad:canvas_Loaded},
              context:null
       });
 
}
 
// Callback from Silverlight when the initial canvas has been loaded
function canvas_Loaded(control, userContext, rootElement)
{
        g_AgControl = control;
        g_AgRoot = rootElement;
       g_AgCanvas = control.content.findName("pathsCanvas");
       g_AgInk = control.content.findName("inkEl");
       g_AgLoaded = true;
}
 
 
// ===== Utils, misc =====
 
function IsEditMode()
{
       return( !GetIsSearch() && !GetIsPreviewPane() && !GetIsReadOnly() ) ;
}

Ink and Paths

Here I'll let you read the code first.  This is pretty lifted much verbatim from Laurence Moroney's blog; with the additional step of keeping a string array of all the ink paths in memory.  We'll use that as the way to load and save ink to Groove, later.


// ===== Ink data and event handling =====
 
// We keep a (script) array: each element of the array is the xaml text of one drawn line.
// This array is stored in the Groove record, as well-formed XML:  all the lines, then wrapped in a "Canvas" element.
// To load this back in to Groove we parse the data with XMLDOM and walk its child elements,
// passing each one to Silverlight in turn.
 
var g_AllPaths = new Array();
 
var g_CurrentStroke = null;
 
function inkInitialize()
{
       g_CurrentStroke = null;
       g_AllPaths = new Array();
}
 
// Mouse down event handler from the InkPresenter
function inkMouseDown(sender,args)
{
       if(!IsEditMode())
       {
              // Do nothing
              return;
       }
       // Capture the Mouse
       g_AgInk.CaptureMouse();
       // Create a new stroke
       g_CurrentStroke = g_AgControl.content.createFromXaml('<Stroke/>');
       // Assign a new drawing attributes element to the stroke
       // This, as its name suggests, defines how the stroke will appear
       var da = g_AgControl.content.CreateFromXaml('<DrawingAttributes/>');
       g_CurrentStroke.DrawingAttributes = da;
       // Now that the stroke has drawing attributes
       // Let's define them...
       g_CurrentStroke.DrawingAttributes.Width = 1;
       g_CurrentStroke.DrawingAttributes.Height = 1;
       g_CurrentStroke.DrawingAttributes.Color = "Black"
       g_CurrentStroke.DrawingAttributes.OutlineColor = "Black"
       g_CurrentStroke.StylusPoints.AddStylusPoints(args.GetStylusPoints(g_AgInk));
       g_AgInk.Strokes.Add(g_CurrentStroke);
}
 
// Add the new points to the Stroke we're working with
function inkMouseMove(sender,args)
{
       if(!IsEditMode())
       {
              // Do nothing
              return;
       }
       if (g_CurrentStroke != null)
       {
              g_CurrentStroke.StylusPoints.AddStylusPoints(args.GetStylusPoints(g_AgInk));
       }
}
 
// Release the mouse
function inkMouseUp(sender,args)
{
       if(!IsEditMode())
       {
              // Do nothing
              return;
       }
       var pathString = ""
       var pathElement = "<Path Stroke='Black' Data='"
       var n = g_CurrentStroke.StylusPoints.Count;
       for(i=0;i<n;i++)
       {
              if(i==0)
              {
                     pathString +="M "
              }
              else
              {
                     pathString +="L "
              }
              pathString+= + g_CurrentStroke.StylusPoints.GetItem(i).X + "," +            
              g_CurrentStroke.StylusPoints.GetItem(i).Y + " "
       }
       pathElement+=pathString+"' />"
       addPath(pathElement);
       g_AgInk.Strokes.Remove(g_CurrentStroke);
       g_CurrentStroke = null;
       g_AgInk.ReleaseMouseCapture();
}
 
 
// =====
 
// Add a path (xaml string) to the current drawing
// and to the in-memory array of path strings
function addPath(path)
{
       var elPath = g_AgControl.content.createFromXaml(path);
       g_AgCanvas.children.add(elPath);
       g_AllPaths[g_AllPaths.length] = path;
}

Load and Save

To save the user's drawing into Groove, we use the form's OnBeforeSubmitData callback:

  • Join the array of paths we know about
  • Wrap them all inside a <Canvas> tag
  • Write this to the "Data" field on the form.

So when you hit Save, the current version of the model is stored away in the Groove record, and diseminated to any other members in the workspace.


// Save current array of paths into the Groove record
// (writing into the UI document, not the underlying record directly)
function savePaths()
{
       var xaml = "<Canvas>" + g_AllPaths.join("") + "</Canvas>";
       SetHTMLFieldValue("Data", xaml);
}
 

To load a drawing from the current record -- when previewing a record, or when opening an existing record for editing -- I took a slightly more convoluted route.  I read the value of the Data field from the current record, and parse it with a regular XMLDOM object.  This gives us some advance validation that the data is well-formed XML, and it also makes it really easy to separate out each of the Path elements, then load them up into the Silverlight control.


// Load paths from an existing Groove record
function tryLoadPaths()
{
       // The Silverlight control loads asynchronously, so wait until it's called back to the page.
       if(g_AgLoaded && g_AgControl && g_AgRoot && g_AgCanvas && g_AgInk)
       {
              GetApp().ClearStatusBarMessage();
              loadPaths();
              return;
       }
       GetApp().DisplayStatusBarMessage("Waiting for Silverlight to initialize...", GrooveMessageBoxIcon_None );
       window.setTimeout( tryLoadPaths, 100 );
}
 
function loadPaths()
{
       try
       {
              if(g_AgCanvas.children)
              {
                     // Clear any existing paths from the canvas
                     g_AgCanvas.children.clear();
              }
              var xaml = GetHTMLFieldValue("Data");
              if(xaml)
              {
                     var dom = new ActiveXObject("Microsoft.XMLDOM");
                     dom.loadXML(xaml);
                     var p = dom.documentElement;
                     for( var j=0; j<p.childNodes.length; j++ )
                     {
                           var path = p.childNodes(j).xml;
                           addPath(path);
                     }
              }
       }
       catch(e)
       {
              alert( "LoadPaths: " + e.description);
       }
}

There's one little wrinkle with the loader function:  Silverlight might not be ready yet.  This happens, for example, when editing an existing Groove record:  Groove calls OnBeforeInitialize, we spin up the Silverlight control and have it load the initial canvas; Groove loads the data record, and calls the OnAfterInitialize form script, but Silverlight hasn't yet called back to say it's ready.  To avoid this case, I just set a little timer (window.setTimeout) to try again a few milliseconds later.

All done!

Here's the tool template.  Use at your own risk, not production quality, no warranties and so on.