Wednesday 24 February 2016

Extending an Image field (pt 2) - adding a SPEAK dialog

This is the second part of a 2-part post about how to extend the Sitecore image field, and add an image overlaid on top of the main image. If you haven't already, check out part 1 to see how to extend the image field. This post will focus on the SPEAK component which will allow the user to set the top/left coordinates that specify where our overlay will be placed over the main image.

So if you remember back to when we set up the Overlay button on our new field, we updated the Message field to use "overlay". Let's handle this message in our ImageWithOverlay.cs class.
/// The overlay message.
private const string OverlayMessage = "overlay";

/// The overlay application location.
private const string OverlayAppLocation = "/sitecore/client/Your Apps/OverlaySelector";

public override void HandleMessage(Message message)
{
    if (message["id"] != ID)
    {
        return;
    }

    string[] command = message.Name.Split(':');
    Assert.IsTrue(command.Length > 1, "Expected message format is control:message");

    if (command[1] == OverlayMessage)
    {
        Sitecore.Context.ClientPage.Start(this, "Overlay");
        return;
    }

    base.HandleMessage(message);
}

public void Overlay(ClientPipelineArgs args)
{
    if (args.IsPostBack)
    {
        if (!args.HasResult)
        {
            return;
        }

        XmlValue.SetAttribute(Models.Constants.CoordinatesAttribute, args.Result);
        Update();
        SetModified();
        SheerResponse.Refresh(this);
    }
    else
    {
        UrlString urlString = new UrlString(OverlayAppLocation);

        Item selectedImage = GetMediaItem();
        if (selectedImage != null)
        {
            urlString["fo"] = selectedImage.Uri.ToString();
        }

        string coords = XmlValue.GetAttribute(Models.Constants.CoordinatesAttribute);
        if (!string.IsNullOrEmpty(coords))
        {
            urlString["coords"] = coords;
        }

        SheerResponse.ShowModalDialog(new ModalDialogOptions(urlString.ToString()) { Width = "800px", Height = "275px", Response = true, ForceDialogSize = true });
        args.WaitForPostBack();
    }
}


This code handles the message and will open our SPEAK application that we're about to make. We're passing the existing coordinates (if they're set) and the Uri of the main image (so that we can display it in our SPEAK dialog).

Ok let's build our SPEAK app. This will show our main image with our overlay image over top, and allow the user to drag the overlay around to set its coordinates. It'll also have a 'save' and 'cancel' button which sets (or not) the coordinates on the main image field.

In Sitecore Explorer, expand core/sitecore/client/Your Apps. Right click it and create a /sitecore/client/Business Component Library/Templates/Pages/Speak-DialogPage called "OverlaySelector". Set the following properties:

  • Theme: Oxford

  • Subthemes: Dialogs

Open the design layout (right click -> tasks -> Design Layout, or Ctrl+U) and set
  • Layout: /sitecore/client/Speak/Layouts/Layouts/Speak-Layout


Add the following renderings:
TypeIDLocationOther
PageCodePage.CodePageCodeScriptFileName: /Scripts/Speak/Overlay.js
DialogPage.Body
DialogHeaderDialogHeader
DialogFooterDialogFooter
DialogContentMDialogContent
TextHeaderTitleDialogHeader.TitleText: Select a teardrop position
SectionMainImageDialogContent.Main
ImageOverlayImageMainImage.ContentAlt: Overlay, Height: 300, Width: 245, ImageUrl: /Content/Images/overlay.png
TextCoordinatesDialogContent.MainText: 100,100
ButtonSaveButtonDialogFooter.ButtonButtonType: Primary, Text: Select
ButtonCancelButtonDialogFooter.ButtonButtonType: Primary, Text: Cancel
RuleSaveButtonRulePage.BodyField: Rule, RuleItemId: (see below), TargetControl: SaveButton, Trigger: click
RuleCancelButtonRulePage.BodyField: Rule, RuleItemId: (see below), TargetControl: CancelButton, Trigger: click
Under your OverlaySelector item, create a /sitecore/cilent/Speak/Templates/Pages/PageSettings item which will have the settings for our dialog (for now, just the 2 rules for our buttons).
Under the PageSettings item, create: /sitecore/client/Speak/Layouts/Renderings/Resources/Rule Definition called CancelButtonRuleDefinition with rule: where always close the dialog
/sitecore/client/Speak/Layouts/Renderings/Resources/Rule Definition called SaveButtonRuleDefinition with rule: where always the dialog return value to component Coordinates text

The final step is to add our javascript for the component. We'll use jQuery UI to make the overlay draggable.
define(["sitecore", "jquery", "jqueryui"], function (_sc, $, ui) {
    var overlaySelectorDialog = _sc.Definitions.App.extend({
        initialized: function () {
            var app = this;
            var scale = 1;

            var itemUriString = _sc.Helpers.url.getQueryParameters(window.location.href)['fo'];
            var itemPath = null;
            try {
                var itemUri = new URL(itemUriString);
                itemPath = itemUri.pathname;
                if (itemPath == "" || itemPath.indexOf("?") > -1) throw "Invalid URL";
            } catch (e) {
                // Doesn't support URL (IE and pretty much FF as well)
                var slashes = itemUriString.indexOf("//");
                var query = itemUriString.indexOf("?");
                if (slashes > -1) itemPath = itemUriString.substring(slashes, query > -1 ? query : itemUriString.length);
            }

            var mainImage = document.querySelector('[data-sc-id="MainImage"]');
            if (itemPath == null || itemPath == "") {
                alert("Couldn't parse item URL for your background image");
            } else {
                var itemUriSplit = itemPath.substring(2).split("/");
                var database = new _sc.Definitions.Data.Database(new _sc.Definitions.Data.DatabaseUri(itemUriSplit[0]));
                database.getItem(itemUriSplit[1], function (item) {
                    if (item == null) alert("Couldn't find background image item in database for unknown reason");
                    else {
                        var imgWidth = parseInt(item.Width);
                        var imgHeight = parseInt(item.Height);
                        if (imgWidth > imgHeight) {
                            scale = 500 / imgWidth;
                            mainImage.style.height = Math.round(scale * imgHeight) + "px";
                        } else {
                            scale = 500 / imgHeight;
                            mainImage.style.width = Math.round(scale * imgWidth) + "px";
                        }
                        
                        mainImage.style.backgroundImage = "url('" + item.$mediaurl.replace("thn=1", "") + "&w=500')";

                        var coords = _sc.Helpers.url.getQueryParameters(window.location.href)['coords'];
                        if (coords != null && coords != "") {
                            app.Coordinates.set('text', coords);
                            var coordsSplit = coords.split(",");
                            jQuery('[data-sc-id="OverlayImage"]').css({ "left": (parseInt(coordsSplit[0]) * scale) + "px", "top": (parseInt(coordsSplit[1]) * scale) + "px" });
                        }
                    }
                });
            }

            mainImage.style.height = "500px";
            mainImage.style.width = "500px";
            mainImage.style.backgroundSize = "cover";

            jQuery('[data-sc-id="OverlayImage"]').draggable({
                containment: '[data-sc-id="MainImage"]',
                scroll: false,
                start: function (e, ui) {
                    app.Coordinates.set('text', Math.round(ui.position.left / scale) + "," + Math.round(ui.position.top / scale));
                },
                drag: function (e, ui) {
                    app.Coordinates.set('text', Math.round(ui.position.left / scale) + "," + Math.round(ui.position.top / scale));
                },
                stop: function (e, ui) {
                    app.Coordinates.set('text', Math.round(ui.position.left / scale) + "," + Math.round(ui.position.top / scale));
                }
            });
        }
    });
    return overlaySelectorDialog;
});
I've uploaded the full code to Github if you'd like to try it out for yourself.

Extending an Image field in Sitecore (part 1)

On my last project one of the more fun things I got to play with was the SPEAK framework, and today's posts (which will be quite large) will outline how to extend the default Sitecore Image field. We're simply going to add an additional icon which is overlaid on top of our main image (like a watermark), and use a simple SPEAK interface to allow it to be positioned (by being dragged, and setting x,y coordintes in pixels) by the content author.
So, first things first: since we're extending the Image field, let's go into the core database and make a duplicate of that field, which you can find at /sitecore/system/Field types/Simple Types/Image. I'm going to call mine "Image With Overlay". Let's also add a new button to open our SPEAK interface, so expand the Menu folder under the new item, and duplicate the Browse button; call it "Overlay", and change the Display Name field to "Overlay" as well. Now there are 2 more things we need to change: in the "Image With Overlay" item we just created, we need to update the Control field, and put overlay:ImageWithOverlay (we'll get back to this later). In the "Overlay" button item, update the Message field so that "open" is now "overlay". It should look like contentimage:overlay(id=$Target).
Ok now that we've got our field defined in Sitecore, let's add the code side of things. Let's extend the default Sitecore.Data.Fields.ImageField and add a new property called OverlayCoordinates to store the coordinates at which to display our overlay. Hopefully you're familiar with what Sitecore Image fields look like as raw values (if not, turn on raw values and have a look with an image with a custom alt tag and title) - we're going to store our coordinates as a new xml attribute in the same way, and this OverlayCoordinates property does just that.
using Sitecore.Data.Fields;

public class ImageWithOverlayField : ImageField
{
    public ImageWithOverlayField(Field innerField)
        : base(innerField)
    {
    }
    
    public ImageWithOverlayField(Field innerField, string runtimeValue)
        : base(innerField, runtimeValue)
    {
    }
    
    public string OverlayCoordinates
    {
        get
        {
            return GetAttribute(Constants.CoordinatesAttribute) ?? Constants.OverlayDefaultCoordinates;
        }

        set
        {
            SetAttribute(Constants.CoordinatesAttribute, value ?? Constants.OverlayDefaultCoordinates);
        }
    }
    
    public static implicit operator ImageWithOverlayField(Field field)
    {
        return field == null ? null : new ImageWithOverlayField(field);
    }
}
Ok now that we've extended our field to store the coordinates attribute, let's extend the actual class which renders our Image field, and show our overlay over top in the preview. The easiest way is to decompile Sitecore.Shell.Applications.ContentEditor.Image and extend+reuse what we can, and copy+update what we cannot reuse (ie. if it's a private method).
using System;
using System.Linq;
using System.Text;
using System.Web.UI;
using Sitecore;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Globalization;
using Sitecore.Resources.Media;
using Sitecore.Shell.Applications.ContentEditor;
using Sitecore.Text;
using Sitecore.Web;
using Sitecore.Web.UI.Sheer;
using Convert = System.Convert;

public class ImageWithOverlay : Image
{
    protected override void DoRender(HtmlTextWriter output)
    {
        Assert.ArgumentNotNull(output, "output");
        Item mediaItem = GetMediaItem();

        string src = GetSrc();
        string str1 = " src=\"" + src + "\"";
        string str2 = " id=\"" + ID + "_image\"";
        string str3 = " alt=\"" + (mediaItem != null ? WebUtil.HtmlEncode(mediaItem["Alt"]) : string.Empty) + "\"";
        string coordinates = XmlValue.GetAttribute(Common.Constants.CoordinatesAttribute);

        if (string.IsNullOrEmpty(coordinates))
        {
            coordinates = Common.Constants.OverlayDefaultCoordinates;
        }

        int[] coords = coordinates.Split(',').Select(int.Parse).ToArray();

        // base.DoRender(output);
        output.Write("
"); output.Write("
"); string dimensions = ""; string overlayDimensions = ""; int[] padding = { 12, 8 }; if (mediaItem != null) { int width = Convert.ToInt32(mediaItem["Width"]); int height = Convert.ToInt32(mediaItem["Height"]); double scale = 128.0 / height; dimensions = "width=\"" + Math.Round(scale * width) + "px\" height=\"" + Math.Round(scale * height) + "px\""; overlayDimensions = "left:" + (Math.Round(coords[0] * scale) + padding[0]) + "px;top:" + (Math.Round(coords[1] * scale) + padding[1]) + "px;"; } else { overlayDimensions = "left:" + padding[0] + "px;top:" + padding[1] + "px;"; } output.Write(""); output.Write(""); output.Write("
"); output.Write("
"); string details = GetDetails(); output.Write(details); output.Write("
"); output.Write("</div>"); } private Item GetMediaItem() { string attribute = XmlValue.GetAttribute("mediaid"); if (attribute.Length <= 0) { return null; } Language language = Language.Parse(ItemLanguage); return Client.ContentDatabase.GetItem(attribute, language); } private string GetSrc() { string src = string.Empty; MediaItem mediaItem = GetMediaItem(); if (mediaItem != null) { MediaUrlOptions thumbnailOptions = MediaUrlOptions.GetThumbnailOptions(mediaItem); int result; if (!int.TryParse(mediaItem.InnerItem["Height"], out result)) { result = 128; } thumbnailOptions.Height = Math.Min(128, result); thumbnailOptions.MaxWidth = 640; thumbnailOptions.UseDefaultIcon = true; src = MediaManager.GetMediaUrl(mediaItem, thumbnailOptions); } return src; } private string GetDetails() { string str1 = string.Empty; MediaItem mediaItem = GetMediaItem(); if (mediaItem != null) { Item innerItem = mediaItem.InnerItem; StringBuilder stringBuilder = new StringBuilder(); XmlValue xmlValue = XmlValue; stringBuilder.Append("
"); string str2 = innerItem["Dimensions"]; string str3 = WebUtil.HtmlEncode(xmlValue.GetAttribute("width")); string str4 = WebUtil.HtmlEncode(xmlValue.GetAttribute("height")); if (!string.IsNullOrEmpty(str3) || !string.IsNullOrEmpty(str4)) { stringBuilder.Append(Translate.Text("Dimensions: {0} x {1} (Original: {2})", str3, str4, str2)); } else { stringBuilder.Append(Translate.Text("Dimensions: {0}", str2)); } stringBuilder.Append("
"); stringBuilder.Append("
"); string str5 = WebUtil.HtmlEncode(innerItem["Alt"]); string str6 = WebUtil.HtmlEncode(xmlValue.GetAttribute("alt")); if (!string.IsNullOrEmpty(str6) && !string.IsNullOrEmpty(str5)) { stringBuilder.Append(Translate.Text("Alternate Text: \"{0}\" (Default Alternate Text: \"{1}\")", str6, str5)); } else if (!string.IsNullOrEmpty(str6)) { stringBuilder.Append(Translate.Text("Alternate Text: \"{0}\"", str6)); } else if (!string.IsNullOrEmpty(str5)) { stringBuilder.Append(Translate.Text("Default Alternate Text: \"{0}\"", str5)); } else { stringBuilder.Append(Translate.Text("Warning: Alternate Text is missing.")); } stringBuilder.Append("
"); stringBuilder.Append("
"); string str7 = WebUtil.HtmlEncode(xmlValue.GetAttribute(Common.Constants.CoordinatesAttribute)); stringBuilder.Append(!string.IsNullOrEmpty(str7) ? Translate.Text("Overlay coordinates: {0}", str7) : Translate.Text("Overlay coordinates: No coordinates set, using 100,100.")); stringBuilder.Append("
"); str1 = stringBuilder.ToString(); } if (str1.Length == 0) { str1 = Translate.Text("This media item has no details."); } return str1; } }
You'll notice the DoRender() method is where we're now including our overlay.png over top of the main image using relative and absolute CSS positioning. I've also added an extra line in the GetDetails() method which shows the user the currently set coordinates, like the "dimensions" and "alt" are currently shown below the image preview.
Ok so now that we've got our classes, let's hook up the Sitecore side of things with the code. For this, we'll need a new include file (which it's always best to put in a "zzz" folder so that it's included last).
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <fieldTypes>
      <fieldType name="ImageWithOverlay" type="SpeakImageOverlay.Models.Fields.ImageWithOverlayField, SpeakImageOverlay" />
    </fieldTypes>
    <controlSources>
      <source mode="on" namespace="SpeakImageOverlay.Models.Controls" assembly="SpeakImageOverlay" prefix="overlay"/>
    </controlSources>
  </sitecore>
</configuration>
The fieldType you'll notice is the ImageWithOverlayField class we created. The source namespace/assembly is where the ImageWithOverlay class lives. Do you remember back to the first step in the core database where we set our Control field to overlay:ImageWithOverlay? Hopefully you'll see now that "overlay" is the source prefix, and "ImageWithOverlay" is the field name.
Ok at this point you should be able to work with your new field in Sitecore! The Overlay button won't do anything, but you should see your overlay image and be able to set the main image as usual. Let's bind everything up using Glass Mapper so we can use it on the frontend as well.
I'm just using a basic Glass Mapper setup, so in GlassMapperScCustom.cs, in CreateResolver() where the container is being created, let's add the line: container.Register(Component.For().ImplementedBy().LifeStyle.Transient);. If you don't have a container set up, have a look into using the Glass.Mapper.Sc.CastleWindsor (make sure it's a compatible version). This will use our ImageOverlayMapper where possible to map our images. The code for this is pretty straightforward. I found it easier to copy some code from SitecoreFieldImageMapper.cs rather than extending it.
public class ImageWithOverlay : Glass.Mapper.Sc.Fields.Image
    {
        public virtual string OverlayCoordinates { get; set; }
    }
using System;
using Fields;
using Glass.Mapper.Sc;
using Glass.Mapper.Sc.Configuration;
using Glass.Mapper.Sc.DataMappers;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;

public class ImageOverlayMapper : AbstractSitecoreFieldMapper
{
    public ImageOverlayMapper()
        : base(typeof(ImageWithOverlay))
    {
    }
    
    public override object GetField(Field field, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        ImageWithOverlay img = new ImageWithOverlay();
        ImageWithOverlayField sitecoreImage = new ImageWithOverlayField(field);

        SitecoreFieldImageMapper.MapToImage(img, sitecoreImage);
        img.OverlayCoordinates = sitecoreImage.OverlayCoordinates;

        return img;
    }
    
    public override void SetField(Field field, object value, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        ImageWithOverlay img = value as ImageWithOverlay;
        if (field == null || img == null)
        {
            return;
        }

        var item = field.Item;

        ImageWithOverlayField sitecoreImage = new ImageWithOverlayField(field);

        SitecoreFieldImageMapper.MapToField(sitecoreImage, img, item);
        sitecoreImage.OverlayCoordinates = img.OverlayCoordinates;
    }
    
    public override string SetFieldValue(object value, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        throw new NotImplementedException();
    }
    
    public override object GetFieldValue(string fieldValue, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        Item item = context.Service.Database.GetItem(new ID(fieldValue));

        if (item == null)
        {
            return null;
        }

        MediaItem imageItem = new MediaItem(item);
        ImageWithOverlay image = new ImageWithOverlay();
        SitecoreFieldImageMapper.MapToImage(image, imageItem);
        image.OverlayCoordinates = Constants.OverlayDefaultCoordinates;
        return image;
    }
}
Now we can just use our ImageWithOverlay (the one which extends Glass.Mapper.Sc.Fields.Image) in our model and on our page!
@using Glass.Mapper.Sc.Web.Mvc
@model SpeakImageOverlay.Models.IPage

@{
 string[] coordinates = SpeakImageOverlay.Models.Constants.OverlayDefaultCoordinates.Split(',');
 if (Model != null &amp;&amp; Model.PageImage != null &amp;&amp; !string.IsNullOrEmpty(Model.PageImage.OverlayCoordinates))
 {
 coordinates = Model.PageImage.OverlayCoordinates.Split(',');
 }
}

<div style="position:relative;">
 @Html.Glass().RenderImage(Model, m =&gt; m.PageImage, new { style = "position: absolute; max-width: 100%;" }, true)
 <img src="~/Content/images/overlay.png" style="position:absolute;width:50px;top:@coordinates[1]px;left:@coordinates[0]px;" />
</div>
In the next post I'll run through how to add the SPEAK component to set the coordinates of our overlay image.