Introduction
This article shows how to create text annotations over an image, in a WPF application. The technique involves rendering a custom control in the adorner layer of an Image
element, allowing for in-place editing of annotations. The classes used in the demo application can be easily and freely used in other applications to achieve the same functionality.
Background
When you read the newspaper and scribble a thought on the page, you are creating an annotation. The term "annotation" refers to a note which describes or explains part of another document. The Windows Presentation Foundation has built-in support for document annotations, as described here. It does not, however, provide out-of-the-box support for annotating images.
A while back I wrote a blog post about how to annotate an Image
element which happens to reside in a Viewbox
. This article takes that idea and generalizes it so that any Image
can be annotated, not just one contained within a Viewbox
. Another improvement seen in this article's demo application is that the annotations are created "in-place", as opposed to typing the annotation text in a TextBox
somewhere else in the user interface.
The demo app
This article is accompanied by a demo application, available for download at the top of this page. The demo app allows you to create annotations on two images. It contains explanatory text about how to create, modify, and delete annotations.
Here is a screenshot of the demo application, after a few annotations have been created:
Notice the location of the various annotations, relative to entities in the picture. After the Window is made smaller, you will see that the annotations remain "pinned" to those entities:
Even though the dimensions of the Image
element have changed, the annotations remain in the same meaningful locations over the picture. This is an important aspect of image annotations, because the location of an annotation is just as meaningful as its text.
The demo app lets the user delete annotations in several ways. If an annotation loses input focus and has no text, it is automatically deleted. Also, aside from the glaringly obvious 'Delete Annotations' button seen above, you can also delete an annotation by right-clicking on it, to pull up a context menu. For example:
Limitations
The demo app is not a "complete" solution. It does not provide any means of persisting annotations across runs of the application. I did not write annotation persistence code because there are so many different ways that this functionality might be used, that writing my own implementation seemed like a shot in the dark. I did, however, try to write the classes in such a way that it will be straightforward to implement saving and loading of annotations.
The demo app also does not provide any fancy UI features like drag-drop of annotations. That might be a useful feature, but I wanted to keep this simple. Drag-drop in WPF is pretty well documented on the Web, so if you need to add that feature you should be able to find some good reference material out there.
How it works
There are four main participants involved, as seen below:
The ImageAnnotationControl
is what you actually see on the screen which displays, and allows you to edit, annotations. ImageAnnotationControl
is a ContentControl
which exposes one interesting public dependency property, called IsInEditMode
. When that property is true
, a DataTemplate
is applied to the ContentTemplate
property which renders the annotation text in a TextBox
. When IsInEditMode
is false
, the annotation text is rendered in a TextBlock
. The complete XAML for ImageAnnotationControl
is seen below:
<ContentControl
x:Class="ImageAnnotationDemo.ImageAnnotationControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ImageAnnotationDemo"
x:Name="mainControl"
>
<ContentControl.Resources>
-->
<DataTemplate x:Key="EditModeTemplate">
<TextBox
KeyDown="OnTextBoxKeyDown"
Loaded="OnTextBoxLoaded"
LostFocus="OnTextBoxLostFocus"
Style="{DynamicResource STYLE_AnnotationEditor}"
Text="{Binding
ElementName=mainControl,
Path=Content,
UpdateSourceTrigger=PropertyChanged}"
/>
</DataTemplate>
-->
<DataTemplate x:Key="DisplayModeTemplate">
<Border>
<TextBlock
MouseLeftButtonDown="OnTextBlockMouseLeftButtonDown"
Style="{DynamicResource STYLE_Annotation}"
Text="{Binding ElementName=mainControl, Path=Content}"
>
<TextBlock.ContextMenu>
<ContextMenu>
<MenuItem
Header="Delete"
Click="OnDeleteAnnotation"
>
<MenuItem.Icon>
<Image Source="delete.ico" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</TextBlock.ContextMenu>
</TextBlock>
</Border>
</DataTemplate>
<Style TargetType="{x:Type local:ImageAnnotationControl}">
<Style.Triggers>
-->
<Trigger Property="IsInEditMode" Value="True">
<Setter
Property="ContentTemplate"
Value="{StaticResource EditModeTemplate}"
/>
</Trigger>
-->
<Trigger Property="IsInEditMode" Value="False">
<Setter
Property="ContentTemplate"
Value="{StaticResource DisplayModeTemplate}"
/>
</Trigger>
</Style.Triggers>
</Style>
</ContentControl.Resources>
</ContentControl>
ImageAnnotationAdorner
is an adorner which is responsible for hosting an instance of ImageAnnotationControl
. It is added to the adorner layer of the Image
being annotated. ImageAnnotationAdorner
is created and positioned by the ImageAnnotation
class. That class has no visual representation, but simply serves as a handle to an annotation for the consumer (i.e. the demo app's main Window
).
When an ImageAnnotation
is created, it installs an adorner in the annotated Image
's adorner layer, as seen below:
void InstallAdorner()
{
if (_isDeleted)
return;
_adornerLayer = AdornerLayer.GetAdornerLayer(_image);
_adornerLayer.Add(_adorner);
}
When the Image
element is resized and an annotation must be moved to its new location, these methods in ImageAnnotation
are invoked:
void OnImageSizeChanged(object sender, SizeChangedEventArgs e)
{
Point newLocation = this.CalculateEquivalentTextLocation();
_adorner.UpdateTextLocation(newLocation);
}
Point CalculateEquivalentTextLocation()
{
double x = _image.RenderSize.Width * _horizPercent;
double y = _image.RenderSize.Height * _vertPercent;
return new Point(x, y);
}
The _horizPercent
and _vertPercent
fields represent the relative location of an annotation over an picture. Those values are calculated in the ImageAnnotation
constructor, as seen below:
private ImageAnnotation(
Point textLocation, Image image,
Style annontationStyle, Style annotationEditorStyle)
{
if (image == null)
throw new ArgumentNullException("image");
_image = image;
this.HookImageEvents(true);
Size imageSize = _image.RenderSize;
if (imageSize.Height == 0 || imageSize.Width == 0)
throw new ArgumentException("image has invalid dimensions");
_horizPercent = textLocation.X / imageSize.Width;
_vertPercent = textLocation.Y / imageSize.Height;
_adorner = new ImageAnnotationAdorner(
this,
_image,
annontationStyle,
annotationEditorStyle,
textLocation);
this.InstallAdorner();
}
The Window
in the demo app asks ImageAnnotation
to create instances of itself when the user clicks on an Image
. In addition to informing the annotation where it should exist over the Image
, it also specifies two Style
s for the ImageAnnotationControl
, as seen below:
void image_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
Image image = sender as Image;
Point textLocation = e.GetPosition(image);
textLocation.Offset(-4, -4);
Style annotationStyle = base.FindResource("AnnotationStyle") as Style;
Style annotationEdtiorStyle =
base.FindResource("AnnotationEditorStyle") as Style;
ImageAnnotation imgAnn = ImageAnnotation.Create(
image,
textLocation,
annotationStyle,
annotationEdtiorStyle);
this.CurrentAnnotations.Add(imgAnn);
}
Those two Style
objects allow the annotation consumer to specify how annotations should be rendered, both when in edit mode and display mode. The demo app's Style
s, which exist in the main Window
's resources, are seen below:
<!---->
<Style x:Key="AnnotationStyle" TargetType="TextBlock">
<Setter Property="Background" Value="#AAFFFFFF" />
<Setter Property="FontWeight" Value="Bold" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#CCFFFFFF" />
</Trigger>
</Style.Triggers>
</Style>
<!---->
<Style x:Key="AnnotationEditorStyle" TargetType="TextBox">
<Setter Property="Background" Value="#FFFFFFFF" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Padding" Value="-2,0,-1,0" />
</Style>
Revision history
- September 12, 2007 � Created the article