Sunday, September 19, 2010

Hosting HTML in Silverlight (not Out of Browser)

Some Silverlight projects may require that you render HTML content - perhaps a non-Silverlight control or web page. Unfortunately unless the project runs Out of Browser (OOB) the WebBrowser control isn't an option. What can you do?

While there is no easy solution to truly embed HTML inside of the Silverlight rendering engine, it is possible to render the HTML using the browser into a DIV element that is overlaid on your Silverlight application. In fact, because the rendering engine is powerful enough to provide you with all of the sizing events and triggers you need, you can create a very convincing experience displaying HTML in your application - even from the browser.

To illustrate this in action, we'll build a sort of "Silverlight browser" that allows you to navigate to any web page. In reality, we'll load the URL into an IFRAME that lives inside a DIV. What will be important to note is that the DIV automatically moves and resizes when you resize the application, as if it were a part the application itself!

Silverlight Browser

In fact, before we jump into the code, why not click here to check it out yourself? Note that FireFox and IE handle this fine. Chrome creates an infinite loop by placing a scrollbar in the iframe, which resizes it, which then triggers an app resize and ... you get the point. Working with HTML this way in Chrome is fine if you are just injecting the HTML, but using iframes requires some tweaks to avoid scrollbars and set margins for Chrome to behave, and quite frankly I didn't have time to do all of the tweaking for his example.

First, we'll set up the DIV. In order for this to work, we also need to set windowless to true for our Silverlight application, so that we can safely overlay the HTML content. The host page now looks like this:

<form id="form1" runat="server" style="height:100%">
<div id="silverlightControlHost">
    <div id="htmlHost" style="visibility: hidden; position: absolute;"></div>
    <object data="data:application/x-silverlight-2," type="application/x-silverlight-2" width="100%" height="100%">
  <param name="source" value="ClientBin/HtmlExample.xap"/>
  <param name="onError" value="onSilverlightError" />
  <param name="background" value="white" />
              <param name="windowless" value="true" />
  <param name="minRuntimeVersion" value="4.0.50826.0" />
  <param name="autoUpgrade" value="true" />
  <a href="http://go.microsoft.com/fwlink/?LinkID=149156&v=4.0.50826.0" style="text-decoration:none">
    <img src="http://go.microsoft.com/fwlink/?LinkId=161376" alt="Get Microsoft Silverlight" style="border-style:none"/>
  </a>
 </object><iframe id="_sl_historyFrame" style="visibility:hidden;height:0px;width:0px;border:0px"></iframe></div>
</form>

The DIV we'll use is called htmlHost. Notice that we position it absolutely and leave it hidden by default.

Next, we'll create a control for the HTML content. The control will do a few things:

  • It will accept a URL to render
  • It will interact with the DIV to inject an IFRAME that points to the URL
  • It will automatically synchronize the size and position of the DIV to a Grid inside of the Silverlight application

We'll create a control called HtmlHost. In the Xaml, we only define one thing: the Grid we'll use to synchronize with the DIV. Take a look:

<Grid x:Name="LayoutRoot" HorizontalAlignment="Stretch" MinWidth="100" MinHeight="100" VerticalAlignment="Stretch" Background="Aquamarine">
    </Grid>

I gave it a background that contrasts with the rest of the application so you can visually see how the DIV moves as you resize and move the application.

In the code behind, I defined some constants for the attributes to manipulate on the DIV and the markup for the IFRAME. The control has two dependency properties: a HostDiv that points to the id of the DIV it will synchronize with, and a Url to load into the DIV.

namespace HtmlExample
{
    public partial class HtmlHost
    {
        private const string IFRAME =
            @"<iframe height=""100%"" width=""100%"" marginheight=""1"" marginwidth=""1"" src=""{0}""></iframe>";

        private const string ATTR_INNER_HTML = "innerHTML";
        private const string ATTR_LEFT = "left";
        private const string ATTR_TOP = "top";
        private const string ATTR_WIDTH = "width";
        private const string ATTR_HEIGHT = "height";
        private const string ATTR_VISIBILITY = "visibility";
        private const string VISIBLE = "visible";
        private const string HIDDEN = "hidden";
        private const string PX = "{0}px";
        private HtmlElement _div;

        private double _width, _height;

        public static DependencyProperty HostDivProperty = DependencyProperty.Register(
            "HostDiv",
            typeof (string),
            typeof (HtmlHost),
            null);

        public string HostDiv
        {
            get { return GetValue(HostDivProperty).ToString(); }

            set
            {                
                SetValue(HostDivProperty, value);
                if (!DesignerProperties.IsInDesignTool)
                {
                    _div = HtmlPage.Document.GetElementById(value);
                }
            }
        }

        public static DependencyProperty UrlProperty = DependencyProperty.Register(
            "Url",
            typeof (string),
            typeof (HtmlHost),
            null);

        public string Url
        {
            get { return GetValue(UrlProperty).ToString(); }

            set
            {                
                SetValue(UrlProperty, value);
                if (!DesignerProperties.IsInDesignTool)
                {
                    _div.SetProperty(ATTR_INNER_HTML, string.Format(IFRAME, value));
                }
            }
        }

Whenever the host DIV is set, the control grabs an HtmlElement reference to the DIV. Whenever the URL property is set, the DIV is injected with the IFRAME code to point to the URL.

When the control is constructed, we'll wait for it to load, record the current width and height, and then set up the synchronization:

public HtmlHost()
{
    InitializeComponent();
    if (DesignerProperties.IsInDesignTool)
    {
        return;
    }
    Loaded += (o, e) =>
                    {
                        _width = Width;
                        _height = Height;
                        if (_div != null)
                        {
                            Show();
                        }
                    };
}

The synchronization is setup up in the Show method. Here, we set the DIV to be visible. We hook into two events. Whenever the entire application is resized, we'll need to compute a new offset for the DIV. Whenever the grid is resized, we'll need to compute a new height and width. The set up for these hooks looks like this:

public void Show()
{
    _div.RemoveStyleAttribute(ATTR_VISIBILITY);
    _div.SetStyleAttribute(ATTR_VISIBILITY, VISIBLE);
    Application.Current.Host.Content.Resized += Content_Resized;
    LayoutRoot.SizeChanged += LayoutRoot_SizeChanged;
    _Resize();
}

private void LayoutRoot_SizeChanged(object sender, SizeChangedEventArgs e)
{
    _width = e.NewSize.Width;
    _height = e.NewSize.Height;
}

private void Content_Resized(object sender, System.EventArgs e)
{
    _Resize();
}

Notice we're banking on the fact that the only way the grid can be resized is when the application is, therefore we just record the height and width for the _Resize method to use.

The resize is where the magic happens that allows us to place the DIV so it covers our Grid exactly. Take a look:

private void _Resize()
{
    var gt = LayoutRoot.TransformToVisual(Application.Current.RootVisual);
    var offset = gt.Transform(new Point(0, 0));
    _div.RemoveStyleAttribute(ATTR_LEFT);
    _div.RemoveStyleAttribute(ATTR_TOP);
    _div.RemoveStyleAttribute(ATTR_WIDTH);
    _div.RemoveStyleAttribute(ATTR_HEIGHT);

    _div.SetStyleAttribute(ATTR_LEFT, string.Format(PX, offset.X));
    _div.SetStyleAttribute(ATTR_TOP, string.Format(PX, offset.Y));
    _div.SetStyleAttribute(ATTR_WIDTH, string.Format(PX, _width));
    _div.SetStyleAttribute(ATTR_HEIGHT,
                            string.Format(PX, _height));
}

The TransformToVisual gives us a reference to know where our Grid is relative to the entire Silverlight application, which was configured to take up the entire browser window. Doing this, we can compute the offset of the grid from the left and top edge of the application. Then, we can simply take this same offset in pixels and apply it to the top and left style attributes of the DIV. We then apply the height and width, and we're done!

To use the control, I created a simple form that accepts a Url and on the button click will update the control. I used gradients in the background to show how it overlays the Silverlight application. Dropping in the control with a default Url looks like this:

<HtmlExample:HtmlHost Grid.Row="1" x:Name="HtmlHostCtrl" 
Margin="40" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HostDiv="htmlHost" Url="http://www.jeremylikness.com/"/>

Notice I passed the id of the DIV and a starting Url.

You can see the control in action here (and again, if you are in Chrome, you'll see a LOT of action):

And finally, you can grab the source code by clicking here.

Enjoy!

Jeremy Likness

16 comments:

  1. Hey Jeremy

    I do a similar sort of thing in this article: http://www.silverlightshow.net/items/Building-a-Silverlight-Line-Of-Business-Application-Part-6.aspx, but I take a different approach and create the IFrame at runtime. It doesn't have the same sort of "seizure" problem your solution seems to have in Chrome (maybe it might help you solve that problem?). In the sample code for my book (Pro Business Applications with Silverlight 4) I take the control a step further and add a check for whether the application is running in out of browser mode. If so the control uses a WebBrowser control instead of creating an IFrame :).

    Chris Anderson

    ReplyDelete
  2. I am generating IFRAME as well but not tweaking the settings so I'm sure there's something there - normally when I use this I'm not pulling third-party but showing HTML content so I don't get the issue, and just haven't explored the IFRAME further.

    All great feedback - thanks for sharing the link - I'm confident others reading this will gain value from that and be able to take the example even further.

    ReplyDelete
  3. Very cool idea, but even more impressive is that it spiked my 6 GB of RAM and drove my quad-core system to a halt on Chrome after just a few seconds. I didn't know that was possible for a web page to do nowadays. ;)

    Also it crashed on IE9 beta.

    ReplyDelete
  4. Yes, that's why I put the caveats for Chrome. :)

    ReplyDelete
  5. Hey Jeremy, Great Post and timely too. :) Just saved me a bunch of time and I've developed a solution based on the logic you're using with some additions.

    That said I've encountered an issue where the overlay appears perfectly aligned in my Silverlight application when hosted in a test page but when deployed out to the site is a little bit off. I suspect something in the CSS (it's hosted on SharePoint so there are tons of possibilities there :) ) is causing problems. Any input on how that might be resolved? Alternately I'm considering just using an InitParam to provide an offset. Any ideas?

    ReplyDelete
  6. I'm glad it was helpful! So the idea is that the DIV is exactly synchronized with the host container for Silverlight. You might look at applying an explicit style to avoid any margins, but your idea of an offset seems to make sense as well. One thought is to use something like JQuery to compute the offset, and use the DOM interaction to share that with the DIV.

    ReplyDelete
  7. Thanks Jeremy; I've been looking all over the web for a good example of how to show PDFs in Silverlight; this was the one clear, concise example I found.

    The one issue I had is that the image was not showing in the area I set the area I allocated for it (top&left were correct, but it was way too wide and way too narrow) (I am setting the URL on the Click event of a datagrid which lists all available images). I fixed it by adding the following to the end of the Url property SET:

    if (!string.IsNullOrEmpty(value))
    Show();

    ReplyDelete
  8. Can't you just check the "Enable running application out-of the browser" check box in the Projects properties page ?
    This is what I do, no code needed

    Am I missing something ?

    ReplyDelete
  9. Roy, running out of browser allows you to host a web page in out of browser mode. This is for people who do not want to install out of browser and still wish to host external HTML from "in browser" mode (hence the title - NOT out of browser). Hope that helps clarify it.

    ReplyDelete
  10. Hello, I see, thank you (my last posting returned error 501 hence this one)

    My problem is I’m hosting your control in a control that inherits from ContentControl
    At the property setter in your code
    public string HostDiv
    {
    get { return GetValue(HostDivProperty).ToString(); }

    set
    {
    SetValue(HostDivProperty, value);
    if (!DesignerProperties.IsInDesignTool)
    {
    _div = HtmlPage.Document.GetElementById(value);
    }
    }
    }

    The _div equals null

    The control I’m using is from this chap
    http://www.codeproject.com/KB/silverlight/FloatingWindow.aspx?msg=3679003#xx3679003xx

    I create a page like this, where ContentEditorView is a UserControl

    private void ShowNewWindow_Click(object sender, RoutedEventArgs e)
    {
    ContentEditorView ce = new ContentEditorView(); //new
    ce.Height = 700;
    ce.Width = 800;
    host.Add(ce);
    _startPoint.X = _startPoint.X + _offSet;
    _startPoint.Y = _startPoint.Y + _offSet;
    ce.Show(_startPoint);
    }

    Within ContentEditorView I instance your control HtmlExample:HtmlHost

    I know it’s not your work etc, But if you can shed any light on this null I’d be very grateful


    Regards
    Roy

    ReplyDelete
  11. Hello again

    My last post, I am dumb !

    I do not have a div id="htmlHost" style="visibility: hidden; position: absolute;"

    This is beacuse I'm not using the Default.aspx startup page


    I need to have a web page within the ContentControl (Floating window) that can be referenced from yout control.

    hmm, my application is getting a bit "in knots" at the moment.

    ReplyDelete
  12. Hi, probably a dumb question, but I was trying to show the HTML text.(I have the html text and I want to show a preview of that to the user). right now i was appending DIV at the bottom and set 'innerHTML' to the text. I came across this example and i tried to play around to show the HTML text by directly setting the 'innerHTML' instead of setting iframe(as i don't have url).
    but it wouldn't show anything. its blank.
    here is the code i modified

    public string Url
    {
    get { return GetValue(UrlProperty).ToString(); }
    set
    {SetValue(UrlProperty, value);
    if (!DesignerProperties.IsInDesignTool)
    {_div.SetProperty(ATTR_INNER_HTML, value);
    }
    }
    }

    ReplyDelete
  13. I have one qusetion.
    have you tested your solution in firefox? it seems not work

    ReplyDelete
  14. Hello,

    Thanks very much for this, it gets me halfway to where I need to go. What I need, though, it to be able exchange data with the page opened in the IFRAME. Is is possible to gain access to the DOM inside the IFRAME? We have c++ and VB apps where we solved this by using a WinForm, but that doesn't help me in this case. Ideally I'd like to pass an object reference, like the ObjectForScripting property in the WebBrowser class.

    ReplyDelete
  15. Hi,

    The above code is working fine when I set TestPage.Html as start page.
    If I set TestPage.aspx as startpage, it is showing a blank screen.
    I tried all the ways but it is not working. Please let me know the problem with this.

    Thanks,
    Nandakumar

    ReplyDelete