Building an Interactive Hotspot Editor in Optimizely CMS 12

Have you ever wanted to let content editors place interactive hotspots directly on an image—without leaving the CMS? In this post, I’ll walk through how we built a custom Hotspot Module in Optimizely CMS 12 that does exactly that.

What We Built
This solution allows editors to:
* Upload a background image.
* Click directly on the image to create hotspot points.
* Drag existing points around.
* Remove them via a trash dropzone.
* Associate each point with content (e.g. title, description, CTA, slides).

2. Content Types

How It Works
We created two content blocks:

HotSpotDataBlock
Each hotspot is defined by:
X/Y coordinates (relative %).
Heading, subheading, description.
A Link
Optional slides (images for a gallery).

public class HotSpotDataBlock : BaseBlockData
{
    public virtual string Heading { get; set; }
    public virtual string SubHeading { get; set; }
    public virtual XhtmlString Description { get; set; }
    public virtual LinkItem PrimaryLink { get; set; }
    [AllowedTypes(typeof(ImageMediaData))]
    public virtual ContentArea Slides { get; set; }

    public virtual string X { get; set; }
    public virtual string Y { get; set; }
}

MediaBlock
This is the parent block that holds the image and the hotspots:

public class MediaBlock : BaseBlockData, IHotSpot
{
    public virtual string Heading { get; set; }
    
    [UIHint(UIHint.Image)]
    [AllowedTypes(typeof(ImageMediaData))]
    public virtual ContentReference Image { get; set; }

    [UIHint(ImagePoint.UIHint)]
    [ReloadOnChange]
    public virtual string ImageFocalPoint { get; set; }

    [AllowedTypes(typeof(HotSpotDataBlock))]
    public virtual ContentArea FocalPointsItems { get; set; }
}
public interface IHotSpot
{
    ContentReference Image { get; set; }
}

2. Custom Dojo Editor

We registered a custom UIHint (ImagePoint) and used Dojo to create a clickable/drag-and-drop editor experience. The core of it lives in imagepointproperty.js, backed by a custom HTML template (ImagePointProperty.html).

Key capabilities:

  • Click to create a new hotspot.
  • Drag to move hotspots.
  • Drop on the trash icon to delete.
  • Positions stored as percentage-based X|Y strings.
 [EditorDescriptorRegistration(TargetType = typeof(string), UIHint = ImagePoint.UIHint)]
 public partial class ImagePoint : EditorDescriptor
 {
     public ImagePoint()
     {
         ClientEditingClass = "imagepointeditor/imagepointproperty";
     }

     public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
     {
         base.ModifyMetadata(metadata, attributes);

         var owner = metadata.FindOwnerContent() as IHotSpot;

         if (owner == null) return;
         
         var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>();

         metadata.EditorConfiguration["publicUrl"] = urlResolver.GetUrl(owner.Image);
         
     }
 }

 public partial class ImagePoint : EditorDescriptor
 {
     public const string UIHint = "ImagePoint";
 }

define([
    "dojo/_base/declare",
    "dojo/_base/json",
    "dijit/_Widget",
    "epi/shell/DialogService",
    "epi/shell/XhrWrapper",
    "epi/routes",
    "dojo/topic",
    "dijit/_TemplatedMixin",
    "epi/shell/_ContextMixin",

    "dojo/text!./ImagePointProperty.html",
    "dojo/dom",
    "dojo/domReady!",
    "xstyle/css!./styles.css"
],
    function (
        declare,
        json,
        _Widget,
        _dialogService,
        XhrWrapper,
        routes,
        topic,
        _TemplatedMixin,
        _ContextMixin,
        template,
        dom
    ) {
        return declare("ImagePointEditor/imagepointproperty", [
            _Widget,
            _TemplatedMixin, _ContextMixin], {
            templateString: template,

            postCreate: function () {
                var self = this;
                this._setDraggable(this, this.dotImage1, 0);
                this._setDraggable(this, this.dotImage2, 1);
                this._setDraggable(this, this.dotImage3, 2);
                this._setDraggable(this, this.dotImage4, 3);
                this._setDraggable(this, this.dotImage5, 4);

                jQuery(this.dotTrash).droppable({
                    drop: function (event, ui) {
                        ui.helper.data('dropped', true);
                        //console.log(ui.helper);
                        //console.log(ui.helper[0].attributes['data-dojo-attach-point'].value);
                        var ind = ui.helper[0].attributes['data-dojo-attach-point'].value.replace('dotImage', '') - 1;
                        const values = self.value;
                        var dots = values.split(';');
                        var selectedDot = dots[ind].split('|');

                        var originalX = selectedDot[0];
                        var originalY = selectedDot[1];

                        var confirmation = _dialogService.confirmation({
                            title: "Hotspots",
                            description: "Are you sure you want to remove the hotspot?",
                            iconClass: "epi-iconWarning epi-icon--medium"
                        });

                        confirmation.then(function () {
                            ui.helper.data('dropped', true);
                            ui.draggable.remove();
                            dots.splice(ind, 1);

                            var currentValue = dots.join(';');

                            self.value = currentValue;
                            self.parent.editing = true;
                            self._set("value", self.value);
                            self.onChange(self.value);
                            self.parent.editing = false;
                        }).otherwise(function () {
                            ui.helper.data('dropped', false);
                            const rect = self.previewImage.getBoundingClientRect();
                            var pixelX = originalX * (rect.right - rect.left) - 7.5;
                            var pixelY = originalY * (rect.bottom - rect.top) - 7.5;
                            ui.draggable.animate({ top: pixelY, left: pixelX }, 'slow');
                        });
                    }
                });
            },

            _setDraggable: function (self, element, index) {
                jQuery(element).draggable({
                    containment: "parent",
                    start: function (event, ui) {
                        ui.helper.data('dropped', false);
                    },
                    stop: function (event, ui) {
                        if (ui.helper.data('dropped') === false) {                            
                            var finalOffset = $(element).offset();
                            var finalxPos = finalOffset.left;
                            var finalyPos = finalOffset.top;

                            const rect = self.previewImage.getBoundingClientRect();
                            const pixelX = finalxPos - rect.left + 7.5;
                            const pixelY = finalyPos - rect.top + 7.5;
                            const percentageX = Math.round(pixelX / (rect.right - rect.left) * 100) / 100;
                            const percentageY = Math.round(pixelY / (rect.bottom - rect.top) * 100) / 100;

                            self._placePoint(index, pixelX, pixelY);

                            var dots = self.value.split(';');
                            var selectedDot = dots[index].split('|');
                            var newX = selectedDot[0];
                            var newY = selectedDot[1];

                            self.value = self.value.replace(newX, percentageX).replace(newY, percentageY);
                            self.parent.editing = true;
                            self._set("value", self.value);
                            self.onChange(self.value);
                            self.parent.editing = false;
                        }
                    }
                }).css("position", "absolute");
            },

            _setValueAttr: function (val) {
                this.previewImage.src = this.publicUrl;
                this.value = val;
                this._set('value', this.value);

                if (!this.value) {
                    this.dotTrash.style.display = 'none';
                }
                else {
                    this.dotTrash.style.display = 'block';
                }

            },
            isValid: function () {
                return true;
            },

            _previewImageLoaded: function () {
                if (this.value) {
                    const values = this.value.split(';');
                    var self = this;
                    values.forEach(this._convertValue, self);
                }
            },

            _convertValue: function (item, index, context) {
                const values = item.split('|');
                const percentageX = values[0];
                const percentageY = values[1];
                const pixelPosition = this._percentageToPixel(percentageX, percentageY, this.previewImage.getBoundingClientRect());
                this._placePoint(index, pixelPosition.pixelX, pixelPosition.pixelY);
            },

            _setNewPosition: function (e) {
                var attr = e;
                var self = this;

                var confirmation = _dialogService.confirmation({
                    title: "Hotspots",
                    description: "Confirm if you want to create a new hotspot.",
                    iconClass: "epi-iconInfo"
                });

                var currentIndex = 0;
                if (self.value) {
                    var values = this.value.split(';');
                    currentIndex = values.length;
                }

                confirmation.then(function () {
                    const rect = attr.target.getBoundingClientRect();
                    const pixelX = attr.clientX - rect.left;
                    const pixelY = attr.clientY - rect.top;
                    const percentageX = Math.round(pixelX / (rect.right - rect.left) * 100) / 100;
                    const percentageY = Math.round(pixelY / (rect.bottom - rect.top) * 100) / 100;

                    self._placePoint(currentIndex, pixelX, pixelY);

                    if (currentIndex == 0) {
                        self.value = percentageX + "|" + percentageY;
                    }
                    else {
                        self.value += ";" + percentageX + "|" + percentageY;
                    }

                    self.parent.editing = true;
                    self._set("value", self.value);
                    self.onChange(self.value);
                    self.parent.editing = false;
                });
            },

            _getPosition: function (e) {
                const rect = e.target.getBoundingClientRect();
                const pixelX = e.clientX - rect.left;
                const pixelY = e.clientY - rect.top;
                const percentageX = Math.round(pixelX / (rect.right - rect.left) * 100) / 100;
                const percentageY = Math.round(pixelY / (rect.bottom - rect.top) * 100) / 100;

                return { pixelX, pixelY, percentageX, percentageY };
            },

            _placePoint(index, x, y) {

                if (index == 0)
                    this._placeDot(this.dotImage1, x, y);
                else if (index == 1)
                    this._placeDot(this.dotImage2, x, y);
                else if (index == 2)
                    this._placeDot(this.dotImage3, x, y);
                else if (index == 3)
                    this._placeDot(this.dotImage4, x, y);
                else if (index == 4)
                    this._placeDot(this.dotImage5, x, y);
            },

            _placeDot: function (element, x, y) {
                element.style.left = Math.round(x) - 7.5 + "px";
                element.style.top = Math.round(y) - 7.5 + "px";
                element.style.display = "block";
            },

            _percentageToPixel: function (percentageX, percentageY, boundingRect) {
                const pixelX = (boundingRect.right - boundingRect.left) * percentageX;
                const pixelY = (boundingRect.bottom - boundingRect.top) * percentageY;
                return { pixelX, pixelY };
            },

            _resetPosition: function (e) {
                var self = this;
                var confirmation = _dialogService.confirmation({
                    title: "Hotspots",
                    description: "Are you sure you want to remove all the hotspots?",
                    iconClass: "epi-iconWarning epi-icon--medium"
                });

                confirmation.then(function () {
                    self.dotImage1.style.display = "none";
                    self.dotImage2.style.display = "none";
                    self.dotImage3.style.display = "none";
                    self.dotImage4.style.display = "none";
                    self.dotImage5.style.display = "none";
                    self.parent.editing = true;
                    self.value = null;
                    self._set("value", null);
                    self.onChange(null);

                    self.parent.editing = false;
                });
            },
        }
        );
    });
<div class="dijitInline">
     <div style="position:relative;top:0;left:0;background-color:#fff; padding: 1px 1px 0 1px;">
         <img src="" alt="" style="position: relative; top: 0; left: 0; min-width:200px; max-width: 600px; cursor:crosshair;" data-dojo-attach-point="previewImage" data-dojo-attach-event="onclick:_setNewPosition,onload:_previewImageLoaded" />
         <div class="ipdot dot1" data-dojo-attach-point="dotImage1"></div>
         <div class="ipdot dot2" data-dojo-attach-point="dotImage2"></div>
         <div class="ipdot dot3" data-dojo-attach-point="dotImage3"></div>
         <div class="ipdot dot4" data-dojo-attach-point="dotImage4"></div>
         <div class="ipdot dot5" data-dojo-attach-point="dotImage5"></div>
         <div style="padding: 2px 10px; color: hsl(241,77%,12%); font-size: smaller">
             <div style="float:left; padding: 2px 0;">Click image to place point</div>
             <div title="drag here to delete the hotspot" class="iptrash icon epi-iconTrash" data-dojo-attach-point="dotTrash" data-dojo-attach-event="onclick:_resetPosition"></div>
             <!--<div style="float:right; color:#000; padding: 2px 10px;" data-dojo-attach-point="info"></div>-->
             <div style="clear:both"></div>
         </div>
     </div>
 </div>

3. Initialization Module

We wired an IContentEvents.SavedContent event to sync the string-based coordinates into actual HotSpotDataBlock instances inside the block’s FocalPointsItems property.

This ensures that:
* New dots are persisted.
* Removed dots are deleted.
* Coordinates are updated when moved.

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class EventsInitialization : IInitializableModule
{
    private IContentEvents _contentEvents;
    private IContentRepository _contentRepository;
    private IContentLoader _contentLoader;

    public void Initialize(InitializationEngine context)
    {
        _contentEvents ??= ServiceLocator.Current.GetInstance<IContentEvents>();

        _contentRepository ??= ServiceLocator.Current.GetInstance<IContentRepository>();
        _contentLoader ??= ServiceLocator.Current.GetInstance<IContentLoader>();

        _contentEvents.SavedContent += ContentEvents_SavedContent;
    }

    public void Uninitialize(InitializationEngine context)
    {
        _contentEvents ??= ServiceLocator.Current.GetInstance<IContentEvents>();

        _contentEvents.SavedContent -= ContentEvents_SavedContent;
    }

    private void ContentEvents_SavedContent(object sender, ContentEventArgs e)
    {
        if (sender == null || e == null) return;

        if (e.Content is not MediaBlock block)
        {
            return;
        }

        var hotSpots = block.ImageFocalPoint;

        if (!string.IsNullOrEmpty(hotSpots))
        {
            if (block.FocalPointsItems == null) //new
            {
                var newBlock = block.CreateWritableClone() as ImmersiveMediaBlock;
                var dots = hotSpots.Split(";");
                var list = new ContentArea();
                foreach (var dot in dots)
                {
                    var axes = dot.Split("|");

                    var block1 = _contentRepository.GetDefault<HotSpotDataBlock>(newBlock.ContentLink);
                    block1.X = axes[0];
                    block1.Y = axes[1];
                    ((IContent)block1).Name = "HotSpot " + 1;

                    var blockRef = _contentRepository.Save((IContent)block1, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);

                    list.Items.Add(new ContentAreaItem()
                    {
                        ContentLink = blockRef
                    });
                }
                newBlock.FocalPointsItems = list;

                _contentRepository.Save((IContent)newBlock, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);
            }
            else
            {
                var dots = hotSpots.Split(";");
                var currentDots = block.FocalPointsItems.Items.GetContentItems<HotSpotDataBlock>().Select(x => $"{x.X}|{x.Y}").ToList();

                var newBlock = block.CreateWritableClone() as ImmersiveMediaBlock;
                if (!dots.Except(currentDots).Any())
                {
                    if (dots.Length < currentDots.Count) //delete
                    {
                        var list = new ContentArea();

                        var listItems = newBlock.FocalPointsItems.Items.GetContentItems<HotSpotDataBlock>()
                            .Where(x => dots.Contains($"{x.X}|{x.Y}"));
                        foreach (var hotSpotDataBlock in listItems)
                        {
                            list.Items.Add(new ContentAreaItem() {ContentLink = hotSpotDataBlock.ContentLink });
                        }

                        newBlock.FocalPointsItems = list;

                        _contentRepository.Save((IContent)newBlock, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);
                    }

                    return;
                }

                //update 
                if (currentDots.Count == dots.Length)
                {
                    var skippedDots = dots.Except(currentDots).ToList();
                    var touched = currentDots.Except(dots).ToList();

                    var items = newBlock.FocalPointsItems.Items.GetContentItems<HotSpotDataBlock>();
                    var list = new ContentArea();
                    foreach (var focalPointDataBlock in items)
                    {
                        var currentDot = $"{focalPointDataBlock.X}|{focalPointDataBlock.Y}";
                        if (!touched.Contains(currentDot))
                        {
                            list.Items.Add(new ContentAreaItem()
                            {
                                ContentLink = focalPointDataBlock.ContentLink
                            });
                            continue;
                        }

                        var skippedDot = skippedDots.First().Split("|");
                        var writableTargetBlock = (HotSpotDataBlock)focalPointDataBlock.CreateWritableClone();

                        writableTargetBlock.X = skippedDot[0];
                        writableTargetBlock.Y = skippedDot[1];

                        _contentRepository.Save((IContent)writableTargetBlock, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);
                        
                        list.Items.Add(new ContentAreaItem()
                        {
                            ContentLink = writableTargetBlock.ContentLink
                        });
                    }

                    newBlock.FocalPointsItems = list;

                    _contentRepository.Save((IContent)newBlock, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);
                }
                else //insert
                {
                    var processDots = dots.Except(currentDots);
                    var list = new ContentArea();
                    foreach (var hotSpotDataBlock in newBlock.FocalPointsItems.Items.GetContentItems<HotSpotDataBlock>())
                    {
                        list.Items.Add(new ContentAreaItem() { ContentLink = hotSpotDataBlock.ContentLink });
                    }

                    foreach (var dot in processDots)
                    {
                        var axes = dot.Split("|");

                        var block1 = _contentRepository.GetDefault<HotSpotDataBlock>(newBlock.ContentLink);
                        block1.X = axes[0];
                        block1.Y = axes[1];
                        ((IContent)block1).Name = "HotSpot " + (list.Items.Count + 1);
                        var blockRef = _contentRepository.Save((IContent)block1, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);

                        list.Items.Add(new ContentAreaItem()
                        {
                            ContentLink = blockRef
                        } );
                    }

                    newBlock.FocalPointsItems = list;

                    _contentRepository.Save((IContent)newBlock, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);
                }
            }
        }
        else if (block.FocalPointsItems != null)
        {
            var newBlock = block.CreateWritableClone() as MediaBlock;
            newBlock.FocalPointsItems = null;
            _contentRepository.Save((IContent)newBlock, SaveAction.ForceCurrentVersion, AccessLevel.NoAccess);
        }
    }
}

Editor Experience

This was designed with editors in mind:
* Easy visual placement.
* Support for up to 5 hotspots.
* UI feedback and confirmation dialogs.
* Works well with 9:16 images.

Custom CSS (styles.css) handles styling and dot indicators (dot1.png to dot5.png), while jQuery UI provides the draggable interaction layer.

Resources:
https://github.com/Geta/Geta.EPi.HotspotsEditor
https://github.com/ErikHen/ImagePointEditor

Leave a comment