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