Advanced Basics

Create a Graphical Editor Using RichTextBox and GDI+

Ken Spencer

Code download available at:AdvancedBasics0405.exe(130 KB)

Contents

Approach 1: The Subform
Approach 2: The Transparent Form
Approach 3: GDI+ and RichTextBox
One More Solution
Conclusion

Irecently was asked a question by a reader who is developing a graphical editor that supports non-Windows® objects (rectangle, line, circle, image) as well as a Windows object (a text control derived from the rich text control). He wanted to define the Z-order of these objects, but discovered that the text window always hides other objects.

Figuring out how to implement the Z-order took some time and research. I've seen discussions about making controls transparent, but I couldn't find a good answer, so I tried a few approaches of my own to solve the problem. To test solutions, I decided to focus on the problem of hosting graphics such as lines, boxes, circles and such. Let's walk through a few approaches and see what happens.

Approach 1: The Subform

Using a Windows Form to host the objects allows me to put the items on the form and then make the form transparent, thus rendering the items beneath it except for those objects that are on the form itself. I tried this by creating two forms and making one of the forms transparent (by setting the TransparencyKey to the value of the form's BackColor). This seemed to work fine. Then I put this form on the master form as a subform.

To create a subform on a Windows Form you must set the TopLevel property of the subform to False and then create an instance of the subform in the parent, and add the subform to the controls collection of the parent. This will place the subform on the parent form. This approach is pretty slick and it even works in design view in Visual Studio® .NET so you can see your form design while building the application. You might notice that I have not shown you any code yet. I will get to that in a minute when I start describing what works best.

The problem with using a subform approach is that the transparent form functionality only works if the form has its TopLevel property set to True. As I mentioned, when you use a form as a subform you must set the TopLevel property to False and thus you cannot use transparency with that form. I was quite disappointed as this would have been an elegant solution that would allow you to do really creative things with your applications.

Approach 2: The Transparent Form

The second approach also uses a Windows Form as a host for child objects. I had more success with this one than with the subform. This time I set up the second form with a transparent background but the problem is that you have to keep these forms in sync with the main form. For instance, if the user minimizes the main form, you must minimize all of the palette forms. If the main form moves, you must move the palette forms. This requires lots of code and much fiddling to get it right. If you must host items such as non-Windows objects that you can render with GDI+, then this form approach might be appropriate. It can be done, but it will take a ton of work to keep the many forms synchronized. Although the transparent form requires a lot of effort, it does work. In the process of debugging this approach, however, I hit on a solution that seems to work even better.

Approach 3: GDI+ and RichTextBox

This approach does everything in one form except for a drawing palette which is implemented by a second form. It works great but it still needs some fine tuning, which I'll leave as an exercise for you to do on your own.

First, I created a simple form that has two controls. The first control is a button called Drawing Palette. Then I added a RichTextBox and a MainMenu (File Open and Close). So far, this approach is really simple. Now for the fun.

I wanted the application to allow me to open a rich text file (.rtf), then add graphics to it. I could have just used GDI+, but that would not allow the user to add graphics at run time. So, I decided to add a drawing palette as another form, place it at a specific location over the main form, and allow users to draw on it. After much playing around with this extra form approach, I finally found a solution for handling graphics, which I will show you now.

Figure 1 Simple Text File

Figure 1** Simple Text File **

As you can see, Figure 1 shows a simple text file opened in the application with simple graphics displayed over the text. The text is shown in the RichTextBox.

Figure 2 Drawing Palette Over Text

Figure 2** Drawing Palette Over Text **

Figure 2 shows the drawing palette on the Grapher form overlaying the textbox. Grapher is a simple form with one key setting. Its Opacity property is set to 50 percent. This allows the underlying text in the RichTextBox to show through this form. Grapher also has all the widgets (such as border, control box, and so on) turned off, resulting in a borderless form. The toolbar is actually a panel control housing three pictureboxes with the graphics for the buttons.

The version I wrote only supports drawing a straight line and a rectangle. The user can see the underlying text beneath the palette. This allows the user to position the graphics over the text visually. Once finished, the user can click the X to close the palette or display the palette later to add more graphics.

Figure 3 shows the key code from Form1. I have omitted most of the generic code in Figure 3 and Figure 4, such as variable definitions and opening files, to focus on the relevant code. The DrawingPaletteButton_Click event is used to start a drawing operation by allowing the user to drag out the size and location of the drawing palette over the RichTextBox. The DrawingPaletteButton_Click code sets up the cross-hair cursor and sets the DrawingMode variable to True.

Figure 4 Key Code Elements from Grapher.vb

Friend Structure DrawingItem Dim DrawingMode As DrawingModes Dim StartPostion As Point Dim EndPostion As Point Dim BoxSize As Size Dim PaletteX As Integer Dim PaletteY As Integer End Structure Friend Enum DrawingModes Line Rectangle None End Enum Private Sub CloseEditModePicturebox_Click( _ ByVal sender As System.Object, ByVal e As System.EventArgs) _ Handles CloseEditModePicturebox.Click Dim ThisDrawItem As New DrawingItem If IsNothing(ThisParentForm.DrawingItems) Then ThisParentForm.DrawingItems = New Collection End If For Each ThisDrawItem In ThisDrawingItems ThisParentForm.DrawingItems.Add(ThisDrawItem) Next If Not IsNothing(ThisParentForm.DrawingItems) Then ThisParentForm.PaintGraphics() End If Me.Hide() End Sub End Class

Figure 3 Key Code Elements from Form1

Private Sub DrawingPaletteButton_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles DrawingPaletteButton.Click Try Me.MainTextRichTextbox.Cursor = Cursors.Cross DrawingMode = True Catch exc As Exception End Try End Sub Private Sub MainTextRichTextbox_MouseDown(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MouseEventArgs) _ Handles MainTextRichTextbox.MouseDown DrawBoxX = e.X DrawBoxY = e.Y If DrawingMode Then MouseDownDraw = True End If End Sub Private Sub MainTextRichTextbox_MouseMove(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MouseEventArgs) _ Handles MainTextRichTextbox.MouseMove If Not MouseDownDraw Then Exit Sub End If If e.X > DrawBoxX Then BoxWidth = e.X - DrawBoxX StartX = DrawBoxX Else BoxWidth = DrawBoxX - e.X StartX = e.X End If If e.Y > DrawBoxY Then BoxHeight = e.Y - DrawBoxY StartY = DrawBoxY Else BoxHeight = DrawBoxY - e.Y StartY = e.Y End If Me.Refresh() MeGraphics.DrawRectangle(LinePen, _ StartX, StartY, BoxWidth, BoxHeight) End Sub Private Sub MainTextRichTextbox_MouseUp( ByVal sender As Object, _ ByVal e As System.Windows.Forms.MouseEventArgs) _ Handles MainTextRichTextbox.MouseUp If DrawingMode Then Me.MainTextRichTextbox.Cursor = Cursors.Default frmGrapher.StartPosition = FormStartPosition.Manual frmGrapher.XFormOffset = StartX frmGrapher.YFormOffset = StartY GraphicxLoc = Me.Location.X + _ Me.MainTextRichTextbox.Location.X + StartX + XOffset GraphicyLoc = Me.Location.Y + _ Me.MainTextRichTextbox.Location.Y + StartY + YOffset Debug.WriteLine("MainTextRichTextbox_MouseUp:" & _ " graphicxloc= " & GraphicxLoc & _ " graphicyloc= " & GraphicyLoc) frmGrapher.Location = New Point(GraphicxLoc, GraphicyLoc) frmGrapher.Size = New Size(BoxWidth, BoxHeight) frmGrapher.ResetDrawingItems() frmGrapher.ShowDialog() End If DrawingMode = False MouseDownDraw = False End Sub Private Sub MainTextRichTextbox_SelectionChanged(_ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles MainTextRichTextbox.SelectionChanged If DrawingMode Then MainTextRichTextbox.Select(0, 0) End If End Sub Private Sub Form1_Paint( ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint PaintGraphics() End Sub Friend Sub PaintGraphics() Dim ThisDrawItem As New Grapher.DrawingItem Dim xLoc, yLoc As Integer Dim xEndLoc, yEndLoc As Integer MainTextRichTextbox.Refresh() If Not IsNothing(DrawingItems) Then For Each ThisDrawItem In DrawingItems With ThisDrawItem .PaletteX = .PaletteX .PaletteY = .PaletteY xLoc = .PaletteX + .StartPostion.X xEndLoc = (.PaletteX) + .EndPostion.X yLoc = .PaletteY + .StartPostion.Y + (YOffset - 50) yEndLoc = .PaletteY + .EndPostion.Y + (YOffset - 50) If Not DebugOutputComplete Then DebugOutputComplete = True End If Select Case ThisDrawItem.DrawingMode Case Grapher.DrawingModes.Rectangle MeGraphics.DrawRectangle(LinePen, _ xLoc, yLoc, .BoxSize.Width, .BoxSize.Height) Case Grapher.DrawingModes.Line MeGraphics.DrawLine(LinePen, xLoc, _ yLoc, xEndLoc, yEndLoc) Case Else End Select End With Next End If 'Test guidelines If ShowGuidesMenuItem.Checked Then MeGraphics.DrawLine(LinePen, 60, 0, 60, Me.Height) MeGraphics.DrawLine(LinePen, 0, 60, Me.Width, 60) End If End Sub Private Sub MainTextRichTextbox_TextChanged( _ ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles MainTextRichTextbox.TextChanged PaintGraphics() End Sub Private Sub Form1_Resize(ByVal sender As Object, _ ByVal e As System.EventArgs) _ Handles MyBase.Resize If Not MeGraphics Is Nothing Then MeGraphics.Dispose() End If MeGraphics = Me.MainTextRichTextbox.CreateGraphics End Sub

The MainTextRichTextBox_MouseDown sets up the drag operation when the user clicks the mouse button. The MainTextRichTextBox_MouseMove event actually draws the rectangle. When the user releases the mouse button, MainTextRichTextBox_MouseMove fires and the palette is drawn by displaying the Grapher form, as you saw in Figure 2.

Now, let's move to the code in Figure 4. When implemented, the user can click either the line or rectangle graphic shown on the toolbar in Figure 2 and then draw that object with the mouse. Grapher also uses the DrawingMode enumeration to track which type of graphic to draw (line or rectangle). The user can draw as many items on a single palette as they want. Then the user clicks the X on the toolbar.

The drawing operations in Grapher are similar to those in Form1. However, instead of throwing away the rectangle, the definition for the drawing operation is stored in the DrawingItem structure. Then when the user releases the mouse, the instance of the structure is stored in the ThisDrawingItems collection. Finally, when the user clicks the X, the palette is closed by calling CloseEditModePicturebox_Click. This event uses the DrawingItems collection in Grapher to update the DrawingItems collection in Form1.

Refer back to the Paint event in Figure 3. This event calls the PaintGraphics procedure to actually perform the work. The Paint event in Grapher is very similar, except that it does not call a subroutine to handle it. The code in both events processes the DrawingItems (Form1) and ThisDrawingItems (Grapher) and uses GDI+ to display the graphics on the RichTextBox.

The collection of drawing items allows you to easily move drawing items from the palette form to the main form. Thus, you do not need to worry about trying to have a transparent form or control to host the graphics. Instead, the code just draws the graphics on the main form's RichTextBox.

One More Solution

Let's consider drawing everything using GDI+. You could treat each block of text and each graphic as an object and store them in a collection. I would create a custom structure and enum like this:

Enum ObjectTypes Text = 0 Bitmap = 1 EMF = 2 End Enum Structure EditorObject Dim ThisObject Dim ThisObjectType As ObjectTypes Dim X As Integer Dim Y As Integer Dim Z As Integer End Structure

Now, I could create an object and put it in a collection. Next I would iterate through the collection of items and draw them. Since each object has a Z value, I could use that to determine the object's placement relative to the other objects.

Conclusion

As you can see, sometimes the solution to a problem is rather simple. Since just about everything is an object in .NET, you can often get around limitations by using a collection to hold the object and manipulate it. Since I am using a collection of graphics items, I could even save the items to disk alongside the text file and reload them later. I like the third approach I presented as it allows me to separate text and graphics and it makes it easy to use the RichTextBox as the text engine. This way I don't have to devise a cool way to handle the text; I just draw the graphics over the text. The Microsoft® .NET Framework does not limit you in this respect; it usually lets you do it either way.

One last note: the GDI+ code shown in this little app needs some tweaking before it is put into production. I did not fine-tune the coordinate mapping or provide other features that you could easily add to meet your needs.

Send your questions and comments for Ken to  basics@microsoft.com.

Ken Spencer works for 32X Tech (https://www.32X.com), where he provides training, software development, and consulting services on Microsoft technologies.