Silverlight Live Comment Preview Control

April 17, 2009 11:38

The complete Silverlight Live Comment Preview series of posts:

  1. Silverlight Live Comment Preview Control
  2. Silverlight Live Comment Preview Control: Some Touch-up and Now on CodePlex

A short time ago I noticed a problem with the AJAX live comment scripting on my other site (it most likely occurred as a result of my latest BlogEngine.NET upgrade). This was preventing people who were leaving blog comments from being able to preview their comments and any formatting they might have applied before hitting "Save". Rather than fix the problem with the scripting, I looked at it instead as a great opportunity to re-implement the functionality as a Silverlight control.

The basic functionality of this control is this: take user input in real-time from an ASP.NET TextBox control and display it in a TextBlock hosted by the Silverlight control. The user has the option of using formatting tags: [b][/b] for bolded text, [q][/q] for quoted text (displayed in a different font than the other text; suitable for displaying blocks of code and such), [i][/i] for italicized text, and [u][/u] for underlined text.

The end result looks something like:

image

image

There were a couple of challenges in developing this control. First, how to get the user inputted text from the ASP.NET control to the Silverlight control? Turns out the solution is to build a JavaScript to Silverlight bridge. The second challenge was coming up with the algorithm to parse out the formatting tags as the user enters them. This wasn't a big deal for a single set of tags, but there's some special cases, including handling embedded tags (where the user might want text that is both bolded and italicized, for example), handling "stranded" opening or closing tags, and peculiarities such as when you have something like:

					   1: [u][b]here is some formatted text[/u][/b]
	

where the opening and closing tags are entered out of order. I decided to support this scenario to enhance the customer experience. Of course, the parsing itself screams for recursion; that was how I did it.

Full source code is available at the bottom of this post.

The ASP.NET Hosting Page

Of course the control eventually made its way to my web site, but for testing I used the basic ASP.NET page set up when I created my new Silverlight project. That file is called LiveCommentPreviewTestPage.aspx. In it, you'll find the usual Silverlight reference at the top:

   1: <%@ Register Assembly="System.Web.Silverlight" Namespace="System.Web.UI.SilverlightControls" TagPrefix="asp" %>

and the declaration of the Silverlight control itself:

   1: <div style="height:100%;">
   2:    <asp:Silverlight ID="slLivePreview" runat="server" Source="~/ClientBin/ItsCodingTime.LiveCommentPreview.xap" MinimumVersion="2.0.31005.0" Width="100%" Height="100%" />
   3: </div>

The piece that carries the data from ASP.NET/JavaScript to the Silverlight control is this:

   1: <script type="text/javascript">
   2:  
   3: function UpdateLivePreview ()
   4: {
   5:     var sl = document.getElementById ("slLivePreview")        // get silverlight app
   6:     var str = document.getElementById ("TextBox1").value;    // get current text from textbox
   7:  
   8:     sl.content.JStoSLBridge.UpdateDisplay(str);
   9: }
  10:  
  11: </script>

I covered this bridging technique in a previous post, so I'll leave it to the reader to investigate this last aspect further.

Update: Just a note to help you avoid spending half a day wondering why your getElementById function call is not working when you know it should… Because of ASP.NET master page control id mangling, you might want to use:

   1: function UpdateLivePreview()
   2: {
   3:     var sl = document.getElementById('<%=slLivePreview.ClientID%>')       // get silverlight app
   4:     var str = document.getElementById('<%=TextBox1.ClientID%>').value;    // get current text from textbox
   5:  
   6:     sl.content.JStoSLBridge.UpdateDisplay(str);
   7: }

The 'ClientId' property will remain unmangled and match the Id you specified when you defined the control.

Page.xaml

The XAML is pretty straightforward:

   1: <UserControl x:Class="ItsCodingTime.LiveCommentPreview.Page"
   2:     xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
   3:     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
   4:     <Grid x:Name="LayoutRoot" Background="White">
   5:         <Grid.RowDefinitions>
   6:             <RowDefinition></RowDefinition>
   7:         </Grid.RowDefinitions>
   8:  
   9:         <Border Background="BlanchedAlmond" CornerRadius="0" BorderBrush="Azure" BorderThickness="1">
  10:             <ScrollViewer x:Name="textScrollView" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" VerticalScrollBarVisibility="Auto">
  11:                 <TextBlock x:Name="uxLivePreview" TextWrapping="Wrap" Grid.Row="0" FontSize="14"></TextBlock>
  12:             </ScrollViewer>
  13:         </Border>
  14:     </Grid>
  15: </UserControl>

We have a Grid control that contains a Border, the TextBlock used to display the preview text, and a ScrollViewer so that if the text creeps beyond the view we can scroll down. We wrap text, so horizontal scrolling is not necessary.

Adding a "Run" to the TextBlock control

You add formatted (or unformatted) text to a TextBlock with a Run object. Properties on the Run define the extent of the formatting. For example, here I allocate a new Run object defining the text to display, a font weight of "normal", font style of "normal", and use "Arial" as the font:

   1: var newRun = new Run
   2: {
   3:     Text = textForRun,
   4:     FontWeight = FontWeights.Normal,
   5:     FontStyle = FontStyles.Normal,
   6:     FontFamily = new FontFamily ("Arial")
   7: };

You can then add this Run object to the TextBlock like this:

   1: uxLivePreview.Inlines.Add (newRun);

where uxLivePreview is my TextBlock and Inlines is an InlineCollection of Run's belonging to that TextBlock control.

Of course, the above is going to add unformatted text to the TextBlock. Actual formatting options that we're concerned with here are FontWeights.Bold for bolding text and FontStyles.Italic for italicizing text. Also, I use two fonts: Arial for normal text and Courier New for quoted text. For underlining, do this:

   1: newRun.TextDecorations = TextDecorations.Underline;

These formatting characteristics (all but underline, which is represented as a bool class member) are defined at class-scope as:

   1: private FontWeight _fontWeight = FontWeights.Normal;
   2: private FontStyle _fontStyle = FontStyles.Normal;
   3: private readonly FontFamily _fontFamilyNormal = new FontFamily ("Arial");
   4: private readonly FontFamily _fontFamilyQuote = new FontFamily ("Courier New");

This way I use what's there as the defaults for unformatted text. As I'm parsing along, when I come upon an open/close tag, I modify the above members based on the type of tag. This approach comes in handy as I encounter nested tags where you need to apply more than one type of formatting to a block of text. As I encounter each closing tag, I then revert the particular style back to it's "normal" setting and move along.

Page.xaml.cs

The Page.xaml.cs file is where all the real work is done. There are a lot of utility methods in there. I won't spend any time on those since it's pretty obvious what each one does. I would like to spend a little time discussing the more significant methods, however.

UpdateDisplay

   1: [ScriptableMember]
   2: public void UpdateDisplay (string commentText)
   3: {
   4:     // if the text hasn't changed, we're done
   5:     if (commentText == _previousText)
   6:     {
   7:         return;
   8:     }
   9:  
  10:     _previousText = commentText;
  11:  
  12:     uxLivePreview.Inlines.Clear ();    // let's text start with a clean slate
  13:     _textBlockRuns.Clear ();
  14:  
  15: #if DEBUG
  16:     _completeText = commentText;
  17:     Debug.WriteLine ("Complete text to parse [{0}]", _completeText);
  18: #endif
  19:  
  20:     // scan input text for [b][/b], [i][/i], [u][/u], [q][/q] and replace with appropriate formatting
  21:  
  22:     var text = commentText;
  23:     ParseText (ref text);
  24:     if (!string.IsNullOrEmpty (text))
  25:     {
  26:         AddNewRun (text);    // add any remaining text
  27:     }
  28:  
  29:     foreach (var r in _textBlockRuns)
  30:     {
  31:         uxLivePreview.Inlines.Add (r);
  32:     }
  33:  
  34:     return;
  35: }

UpdateDisplay is exposed to JavaScript using the ScriptableMember attribute and is initiated from our aspx web page.

The first thing I do (lines 5-8) is examine the incoming text to see if it's changed from the previously saved text (line 10). If it hasn't, we're done. If it has changed, we keep going.

Next, I pave the way for new text content by clearing out the current content of the control as well as the collection I use to store Run objects (lines 12-13).

In debug code, I save and output the incoming text (lines 15-18).

Now, the good stuff. ParseText is where we dissect the incoming text, examine any formatting tags in place, create the formatted Run's, and add them to our Runs collection (_textBlockRuns). ParseText does this in recursive fashion. See below for a further breakdown of ParseText.

Last, after ParseText has added all of the Run's to _textBlockRuns, we iterate through the collection and add each Run to the TextBlock control (lines 29-32).

ParseText

   1: public void ParseText (ref string text)
   2: {
   3:     if (string.IsNullOrEmpty (text))
   4:     {
   5:         return;
   6:     }
   7:  
   8:     if ((!ContainsOpenTag (text)) && (!ContainsCloseTag (text)))
   9:     {
  10:         // if there's no formatting, then just return the whole string as the only TextBlock Run
  11:         AddNewRun (text);
  12:         text = string.Empty;
  13:         return;
  14:     }
  15:  
  16:     if ((ContainsOpenTag (text)) && (!ContainsCloseTag (text)))
  17:     {
  18:         AddNewRun (text);
  19:         text = string.Empty;
  20:         return;
  21:     }
  22:  
  23:     int closeTagPosition;
  24:  
  25:     if ((!ContainsOpenTag (text)) && (ContainsCloseTag (text)))
  26:     {
  27:         closeTagPosition = FindFirstCloseTagPosition (text);
  28:  
  29:         var ft = GetFirstCloseTagType (text);
  30:  
  31:         if (((ft == FormattingType.Underline) && (_underlinePass))
  32:             || ((ft == FormattingType.Quote) && (_quotePass))
  33:             || ((ft == FormattingType.Bold) && (_fontWeight != FontWeights.Normal))
  34:             || ((ft == FormattingType.Italics) && (_fontStyle != FontStyles.Normal))
  35:         )
  36:         {
  37:             AddNewRun (text.Substring (0, closeTagPosition));
  38:         }
  39:         else
  40:         {
  41:             AddNewRun (text.Substring (0, closeTagPosition + 4));    // corresponding open tag not being processed, so output closing tag
  42:         }
  43:  
  44:         text = text.Substring (closeTagPosition + 4);
  45:  
  46:         return;
  47:     }
  48:  
  49:     // find any untagged text from the start of the string to the first open tag
  50:     var openTagPosition = FindFirstOpenTagPosition (text);
  51:     if (openTagPosition > 0)
  52:     {
  53:         AddNewRun (text.Substring (0, openTagPosition));
  54:         text = text.Substring (openTagPosition);
  55:     }
  56:  
  57:     openTagPosition = 0;
  58:  
  59:     var openTag = text.Substring (0, 3);    // should be guaranteed to always have an opening tag as the first characters
  60:  
  61:     if (openTag == _openTagCollection[(int) FormattingType.Bold])
  62:     {
  63:         var foundClosingTag = true;    // assume there is a corresponding closing tag for now
  64:  
  65:         closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Bold], openTagPosition + 3); // find the corresponding closing tag
  66:  
  67:         // only display with formatting if there is a corresponding closing tag
  68:         if (closeTagPosition != -1)
  69:         {
  70:             _fontWeight = FontWeights.Bold;
  71:         }
  72:  
  73:         if (closeTagPosition == -1)
  74:         {
  75:             foundClosingTag = false;
  76:  
  77:             // use end of string as closing tag location if we can't find one
  78:             closeTagPosition = text.Length;
  79:         }
  80:  
  81:         // look for nested open tag
  82:         if (ContainsOpenTag (text.Substring (openTagPosition + 3, closeTagPosition - openTagPosition - 3)))
  83:         {
  84:             if (foundClosingTag)
  85:             {
  86:                 text = text.Substring (openTagPosition + 3);
  87:             }
  88:             else
  89:             {
  90:                 // get the position of the nested open tag
  91:                 var nestedOpenTagPosition = FindFirstOpenTagPosition (text.Substring (openTagPosition + 3));
  92:                 AddNewRun (text.Substring (openTagPosition, nestedOpenTagPosition - openTagPosition + 3));    // add the section up to the nested open tag
  93:                 text = text.Substring (nestedOpenTagPosition + 3);
  94:             }
  95:  
  96:             ParseText (ref text);
  97:         }
  98:  
  99:         if ((closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Bold], openTagPosition)) != -1)
 100:         {
 101:             text = GenerateAndAddNewRun (text, openTagPosition, closeTagPosition, _openTagCollection[(int) FormattingType.Bold]);
 102:             _fontWeight = FontWeights.Normal;
 103:             ParseText (ref text);
 104:         }
 105:  
 106:         _fontWeight = FontWeights.Normal;
 107:     }
 108:     else if (openTag == _openTagCollection[(int) FormattingType.Italics])
 109:     {
 110:         var foundClosingTag = true;    // assume there is a corresponding closing tag for now
 111:  
 112:         closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Italics], openTagPosition + 3); // find the corresponding closing tag
 113:  
 114:         // only display with formatting if there is a corresponding closing tag
 115:         if (closeTagPosition != -1)
 116:         {
 117:             _fontStyle = FontStyles.Italic;
 118:         }
 119:  
 120:         if (closeTagPosition == -1)
 121:         {
 122:             foundClosingTag = false;
 123:  
 124:             // use end of string as closing tag location if we can't find one
 125:             closeTagPosition = text.Length;
 126:         }
 127:  
 128:         // look for nested open tag
 129:         if (ContainsOpenTag (text.Substring (openTagPosition + 3, closeTagPosition - openTagPosition - 3)))
 130:         {
 131:             if (foundClosingTag)
 132:             {
 133:                 text = text.Substring (openTagPosition + 3);
 134:             }
 135:             else
 136:             {
 137:                 // get the position of the nested open tag
 138:                 var nestedOpenTagPosition = FindFirstOpenTagPosition (text.Substring (openTagPosition + 3));
 139:                 AddNewRun (text.Substring (openTagPosition, nestedOpenTagPosition - openTagPosition + 3));    // add the section up to the nested open tag
 140:                 text = text.Substring (nestedOpenTagPosition + 3);
 141:             }
 142:  
 143:             ParseText (ref text);
 144:         }
 145:  
 146:         if ((closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Italics], openTagPosition)) != -1)
 147:         {
 148:             text = GenerateAndAddNewRun (text, openTagPosition, closeTagPosition, _openTagCollection[(int) FormattingType.Italics]);
 149:             _fontStyle = FontStyles.Normal;
 150:             ParseText (ref text);
 151:         }
 152:  
 153:         _fontStyle = FontStyles.Normal;
 154:     }
 155:     else if (openTag == _openTagCollection[(int) FormattingType.Underline])
 156:     {
 157:         var foundClosingTag = true;    // assume there is a corresponding closing tag for now
 158:  
 159:         closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Underline], openTagPosition + 3); // find the corresponding closing tag
 160:  
 161:         // only display with formatting if there is a corresponding closing tag
 162:         if (closeTagPosition != -1)
 163:         {
 164:             _underlinePass = true;
 165:         }
 166:  
 167:         if (closeTagPosition == -1)
 168:         {
 169:             foundClosingTag = false;
 170:  
 171:             // use end of string as closing tag location if we can't find one
 172:             closeTagPosition = text.Length;
 173:         }
 174:  
 175:         // look for nested open tag
 176:         if (ContainsOpenTag (text.Substring (openTagPosition + 3, closeTagPosition - openTagPosition - 3)))
 177:         {
 178:             if (foundClosingTag)
 179:             {
 180:                 text = text.Substring (openTagPosition + 3);
 181:             }
 182:             else
 183:             {
 184:                 // get the position of the nested open tag
 185:                 var nestedOpenTagPosition = FindFirstOpenTagPosition (text.Substring (openTagPosition + 3));
 186:                 AddNewRun (text.Substring (openTagPosition, nestedOpenTagPosition - openTagPosition + 3));    // add the section up to the nested open tag
 187:                 text = text.Substring (nestedOpenTagPosition + 3);
 188:             }
 189:  
 190:             ParseText (ref text);
 191:         }
 192:  
 193:         if ((closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Underline], openTagPosition)) != -1)
 194:         {
 195:             text = GenerateAndAddNewRun (text, openTagPosition, closeTagPosition, _openTagCollection[(int) FormattingType.Underline]);
 196:             _underlinePass = false;
 197:             ParseText (ref text);
 198:         }
 199:  
 200:         _underlinePass = false;
 201:     }
 202:     else if (openTag == _openTagCollection[(int) FormattingType.Quote])
 203:     {
 204:         var foundClosingTag = true;    // assume there is a corresponding closing tag for now
 205:  
 206:         closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Quote], openTagPosition + 3); // find the corresponding closing tag
 207:  
 208:         // only display with formatting if there is a corresponding closing tag
 209:         if (closeTagPosition != -1)
 210:         {
 211:             _quotePass = true;
 212:         }
 213:  
 214:         if (closeTagPosition == -1)
 215:         {
 216:             foundClosingTag = false;
 217:  
 218:             // use end of string as closing tag location if we can't find one
 219:             closeTagPosition = text.Length;
 220:         }
 221:  
 222:         // look for nested open tag
 223:         if (ContainsOpenTag (text.Substring (openTagPosition + 3, closeTagPosition - openTagPosition - 3)))
 224:         {
 225:             if (foundClosingTag)
 226:             {
 227:                 text = text.Substring (openTagPosition + 3);
 228:             }
 229:             else
 230:             {
 231:                 // get the position of the nested open tag
 232:                 var nestedOpenTagPosition = FindFirstOpenTagPosition (text.Substring (openTagPosition + 3));
 233:                 AddNewRun (text.Substring (openTagPosition, nestedOpenTagPosition - openTagPosition + 3));    // add the section up to the nested open tag
 234:                 text = text.Substring (nestedOpenTagPosition + 3);
 235:             }
 236:  
 237:             ParseText (ref text);
 238:         }
 239:  
 240:         if ((closeTagPosition = text.IndexOf (_closeTagCollection[(int) FormattingType.Quote], openTagPosition)) != -1)
 241:         {
 242:             text = GenerateAndAddNewRun (text, openTagPosition, closeTagPosition, _openTagCollection[(int) FormattingType.Quote]);
 243:             _quotePass = false;
 244:             ParseText (ref text);
 245:         }
 246:  
 247:         _quotePass = false;
 248:     }
 249:  
 250:     return;
 251: }

ParseText is a recursive method that examines input text for open/close formatting tags and add new Run objects to a List collection based on those formatting tags, if any.

The method itself is rather long (about 250 lines), and I won't go through every bit of it. There is a fair amount of repetition in the general manner in which tags are searched for and acted upon. The difference really comes in which tag we're actively working on and what formatting needs to be applied based on that.

So, to break it down just a bit, we do some basic checking right at the beginning: if the string is null or empty (lines 3-6), there's not much we can do. Also, if the text contains no formatting tags, we add the whole thing as unformatted (lines 8-14). Easy, we're done. Another one is if there's only open tags but no closing tags. Last, because we're recursive, we have to consider that we're already working on an open tag, so we look for the case where the text only contains closing tags but no open tags (starting at line 25). From there, we look to see if we are in fact already acting on an open tag and add the text accordingly.

You'll notice that I have two bool's (_underlinePass, _quotePass) and two font attributes (_fontWeight, _fontStyle) that I use as flags to keep track of what formatting I'm currently working on.

Once we're through that, I look for the first open tag position (line 50). If it's not at position 0, then I add up to openTagPosition as an unformatted Run. The rest of the text requires further examination since we are now operating on an open tag.

I get the actual open tag (line 59), then determine which one it is. Let's assume it's a [b] tag and go from there. We enter the section of code begun on line 63 by first finding the first, corresponding close tag that appears after our open tag (line 65). We only display the text as bolded if we find a matching closing tag (lines 68-71). If we didn't find a matching close tag, we do some bookkeeping (lines 73-79). Then it's time to look for nested open tags where a user might want to apply more than one formatting attribute to some text (lines 82-97). Based on what we find, we break out that section of text and make a recursive call back into ParseText to handle just that bit of text. Once we're done with that, we check for a bold closing tag. It's possible to be left with something like "and here we have some bolded text[/b]", where we already acted on everything up to "and…" but just need to finish it up now (lines 99-104). Last, we reset the _fontWeight flag to Normal (line 106). We're done processing that bold run of text.

You'll notice that the recursion works by breaking off a section of text up to a certain point based on whether we find a nested open tag, a closing tag, or something else. It then processes each segment in a sort of serial fashion, using the four flags mentioned above to know what formatting is currently being processed. This seemed to be the most straightforward and intuitive way of processing runs of text from the main user input text.

The other blocks of code to handle each formatting type act pretty much the same. In fact, there's probably some room for simplification there since a good chunk of the code is duplicated, differing only by the type of tag we're acting on. I might come back and revisit at some point, but not now. ;-)

Conclusion

I found this a good lesson in how to effectively communicate information from an ASP.NET page to a Silverlight control while also re-enabling the live comment preview feature of my blog. Feel free to tweak or use as-is the code below.

Download: LiveCommentPreview.zip

References

The TextBlock Inline property in Silverlight 2


Comments

All comments are moderated and require approval before display. Thanks for your understanding.

Add comment


 


[b][/b] - [i][/i] - [u][/u] - [q][/q]



Preview