I've been wanting a Visio-like diagramming tool for a long time, and took a look at three small, usable, documented packages. Given the complexity of the code base, .NET version issues, lack of documentation, and obvious bugs or missing features in those packages, it really wasn't worth diving deeper. Thus FlowSharp was born.
Reason for this update:
- Refactoring of code components into services
- UI now implements docking panels and multiple documents
- FlowSharp and FlowSharpCode are now unified
- Bookmark navigation of shapes
- Ctrl-Tab navigation of shape selection history
Table of Contents
Build Environment
FlowSharp was built with Visual Studio 2015 and references .NET 4.6.1. VS2015 is required, but the code can also be built referencing .NET 4.5.
The source code is maintained here.
I've been wanting a Visio-like diagramming tool for a long time (for some interesting ideas I have, but that's another story.) While working with Visio through their COM APIs is great, not everyone has Visio, and directly integrating into another application can be a bit overkill, particularly for my purposes.
I figured someone must have written a small, usable, documented package. I found these three:
NShape - .NET Diagramming Framework for Industrial Applications
Pretty good, very comprehensive set of shapes and features, but: connectors act funky though, rotations don't seem to work right, no virtual surface, some visual artifacts when moving large shapes, and very large code base. Code was written for .NET 2.0 and when rebuilding for .NET 4.x, all sorts of "ambiguous reference" errors occurred because there were some implementations between NShape's read only collections and .NET's implementation. Not having a virtual surface was a major show stopper for me.
Crainiate / Open Diagram
The one example looks great, but there's a behind-the-scenes architecture that isn't documented and looks complex. I'm not sure it supports a virtual surface, and learning it looked like a significant undertaking.
dot-net-diagram
Not very complete, no virtual surface, I couldn't get connectors to work.
Given the complexity of the code base, .NET version issues, lack of documentation, and obvious bugs or missing features, it really wasn't worth diving deeper. Thus FlowSharp was born.
Thus, FlowSharp was born. Besides, I wanted to have some fun, own and know the code, and implement the user experience the way I wanted it to work.
Here's the basic list of features:
Drag the surface to move objects around. It's an infinite virtual surface.
The light grey rectangles are a fun "switch" you can turn on in the code that shows the region being updated. Regions for the connectors are bigger than necessary.
Z-ordering, with:
- move to top
- move to bottom
- move up
- move down
is supported.
Shapes can have text (with font and color attributes) and there is a freeform text shape as well.
canvas = new Canvas();
canvas.Initialize(pnlCanvas);
And no, this control is not implemented as a true user control that can be dropped into the Windows designer.
Snazzy!
Anchors (grip handles) are automatically shown when you mouse over a shape. In this case, the shape is also selected, which is indicated with a red border.
Grips:
vs. connection points:
The connection points (little blue Xs that don't show up well on diamonds) appear when a connector's anchor gets close to a shape.
Turtles??? No - I mean snapping anchors automatically to a nearby shape's connection point. This gives the user nice positive feedback that the connector is connected. To disconnect, "jerk" the connector or the connector's anchor away from the connection point.
using System.Drawing;
namespace FlowSharpLib
{
public class Box : GraphicElement
{
public Box(Canvas canvas) : base(canvas) { }
public override void Draw(Graphics gr)
{
gr.FillRectangle(FillBrush, DisplayRectangle);
gr.DrawRectangle(BorderPen, DisplayRectangle);
base.Draw(gr);
}
}
}
That wasn't too hard, was it?
The magic begins here:
public CanvasController(Canvas canvas, List<GraphicElement> elements) : base(canvas, elements)
{
canvas.Controller = this;
canvas.PaintComplete = CanvasPaintComplete;
canvas.MouseDown += OnMouseDown;
canvas.MouseUp += OnMouseUp;
canvas.MouseMove += OnMouseMove;
}
If you don't believe in magic, use the base class (or derive from it for your own special magic):
public class ToolboxController : BaseController
{
protected CanvasController canvasController;
public ToolboxController(Canvas canvas, List<GraphicElement> elements,
CanvasController canvasController) :
base(canvas, elements)
{
this.canvasController = canvasController;
canvas.PaintComplete = CanvasPaintComplete;
canvas.MouseDown += OnMouseDown;
}
In other words, the toolbox is itself a canvas:
OK, how hard is this really?
protected void Paste()
{
string copyBuffer = Clipboard.GetData("FlowSharp")?.ToString();
if (copyBuffer == null)
{
MessageBox.Show("Clipboard does not contain a FlowSharp shape",
"Paste Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
else
{
try
{
GraphicElement el = Persist.DeserializeElement(canvas, copyBuffer);
el.DisplayRectangle = el.DisplayRectangle.Move(20, 20);
el.UpdatePath();
canvasController.Insert(el);
canvasController.DeselectCurrentSelectedElement();
canvasController.SelectElement(el);
}
catch (Exception ex)
{
MessageBox.Show("Error pasting shape:\r\n"+ex.Message,
"Paste Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
So, obviously you can copy and paste between instances of FlowSharp as well as copy & paste within the same instance.
For the latest issues, feature requests, and help wanted, please visit the Open Issues.
Contributions are welcome, and if you want to contribute, please fork the GitHub repo here and submit pull requests.
The goal here was to write some really readable / maintainable code. This meant:
- Small methods (for the most part) with attention to doing only one thing..
- Extension methods to clearly state the operation.
- Use of
?.
safe navigation and ??
null-coalescing operators (?.
requires C# 6.0 and therefore VS2015). - Lots of Linq and anonymous methods are used, again improving readability, in my opinion.
- Good OO design, particularly inversion-of-control, where shapes control and often override default behaviors.
- Clean separation of controllers and models.
I like extension methods. A lot. For example:
DisplayRectangle.TopRightCorner().Move(-anchorSize, 0)
This makes the code quite readable, in my opinion: With the display rectangle, get the top right corner and move it. It's a nice left-to-right progression. Of course, it could have been written something like this:
Move(GetTopRightCorner(DisplayRectangle), -anchorSize, 0)
Which to me is much less readable.
This is a fragment of code that figures out some anchor points:
r = new Rectangle(DisplayRectangle.TopLeftCorner(), anchorSize);
anchors.Add(new ShapeAnchor(GripType.TopLeft, r));
r = new Rectangle(DisplayRectangle.TopRightCorner().Move(-anchorWidthHeight, 0), anchorSize);
anchors.Add(new ShapeAnchor(GripType.TopRight, r));
r = new Rectangle(DisplayRectangle.BottomLeftCorner().Move(0, -anchorWidthHeight), anchorSize);
anchors.Add(new ShapeAnchor(GripType.BottomLeft, r));
r = new Rectangle(DisplayRectangle.BottomRightCorner().Move
(-anchorWidthHeight, -anchorWidthHeight), anchorSize);
anchors.Add(new ShapeAnchor(GripType.BottomRight, r));
Again, I think this is pretty readable, though of course, this code could be optimized in working with the DisplayRectangle
coordinates. But the point here is, that this code is only called when the mouse hovers over a shape. Does it need to be fully optimized? Heck no, and the optimized version is probably less readable.
There are also tradeoffs in optimization. This code fragment gets called whenever the mouse is hovering over an object, and even when the mouse is moving within an object. Do I care? Not really. Conversely, one might think "let's calculate the anchor points whenever the shape has moved." That would not be wise because certain operations, like dragging the canvas which moves all shapes, would then recalculate the anchor points even though we don't need them.
As the above code illustrates, all shapes and shape components are in absolute coordinates. One might think that shape components, like anchor and connection points, ought to be relative to the shape -- there's a certain logic / elegance to that -- but the problem is that it then requires translation from a local coordinate system to the surface coordinate system for everything from shape rendering to UI controller aspects such as "what shape component did the user click on?"
Because I use an absolute coordinate system, drawing components like the anchors is really easy:
protected virtual void DrawAnchors(Graphics gr)
{
GetAnchors().ForEach((a =>
{
gr.DrawRectangle(anchorPen, a.Rectangle);
gr.FillRectangle(anchorBrush, a.Rectangle.Grow(-1));
}));
}
Now, granted, the use of extension methods, Linq, and anonymous methods potentially degrade performance -- it's an interesting tradeoff: code readability vs. performance. Technically, the compiler should optimize the code, but we all know the reality of that.
Writing this really helped me identify what I consider to be good development practices:
- Start with a basic set of requirements:
- Keep a log or journal of must have additional requirements that evolve from the starting requirements.
- Keep a log of "would be nice to have" features, and prioritize them.
- Keep a log of known bugs.
- Commit the code when a feature works.
- When a bug is discovered, fix it right away - the code changes can otherwise be significant if not resolved soon.
- If you need to take a break from some bug, work on another feature that is totally unrelated to the bug so you know the code won't interact.
- Got some code smell? Refactor it now rather than later.
- Try to remember that all those graphic objects need to be disposed!
- Move duplicate code into its own method.
- Testing something like this is not easy because you basically have to play with the UI to test behaviors. Write down basic test scenarios.
- If you're using
if x is [some type]
, then you're not correctly inverting control with a good object design. I had a bunch of those because they were easy and quick to write, but in the end, I refactored them all out. In one case, a complicated nested if
-else
piece 40 line long piece of code got reduced to a single call that the sub-classes handled. - Programming 101:
- Avoid magic numbers, put them in
const
variables instead. I have a few lingering violations of that. - Use meaningful names for everything. Not necessarily as easy as it sounds.
- In the code, I call things "elements", but in the article, I call them "shapes." This is an inconsistency that should probably be rectified. I probably should also refactor the names of the shapes from things like "
Box
" to "BoxShape
." Naming is not easy!
- Code organization: Built in shapes should probably be organized in sub-folder to keep things clean, but then where do you put base classes? Should the sub-folder only contain the concrete shape implementation? Should base classes be put in a separate folder? Even for a simple project like this, code organization is an interesting question.
I do this a lot:
List<GraphicElement> els = intersections.OrderBy(e => elements.IndexOf(e)).ToList();
When this is better:
IEnumerable<GraphicElement> els = intersections.OrderBy(e => elements.IndexOf(e));
As found on Stack Overflow:
Yes, IEnumerable<T>.ToList()
does have a performance impact, it is an O(n) operation though it will likely only require attention in performance critical operations.
One of the advantages of using IEnumerable
is that you can Reverse
the list without affecting the master list! The code could still be further cleaned up for this bad habit.
I like writing code where I know explicitly what the variable type is:
IEnumerable<GraphicElement> els = EraseTopToBottom(el, dx, dy);
In some cases, where it doesn't really matter because knowing the type doesn't really matter, you'll find some var
use cases, like this one:
public void Redraw(GraphicElement el, int dx=0, int dy=0)
{
var els = EraseTopToBottom(el, dx, dy);
DrawBottomToTop(els, dx, dy);
UpdateScreen(els, dx, dy);
}
But generally, I like to know what I'm working with, after all, C# is not Python or Ruby!
I also have a particular dislike for decorating my classes with serialization and designer attributes. It just adds a lot of cruft to an otherwise clean model, especially when you need serialization helpers (aka custom properties) for things like brushes, fonts, pens, and colors. For that reason, even though it added to the code complexity, there are separate classes for managing the properties (suitable for a property grid) and for serialization (currently using XML serialization, but JSON should work well too.)
(In writing this, I improved the object model and removed some custom toolbox handling that was no longer necessary, eliminating 80 lines of code, and adding some code for additional shapes, so the above is just a snapshot of a particular point in time.)
FlowSharp basically took several full weekend days and most weekday evenings to write. So, say 120 hours. That's about 11.5 lines of working code per hour! During this process, it seemed like everything kicked back at me. Here's some of the more memorable moments:
- Background erasing had to compensate for:
- border pen width
- the fact that connection points are drawn partially outside of the shape
- True dynamic connectors are a dissertation in themselves and so I simplified my life tremendously by not supporting them!
- Line caps:
- The line caps GDI provides are useless -- they're tiny
- Only one custom line cap (arrow) is provided by .NET.
- Custom line caps are a PITA. Not currently implemented. Googling, it seems nobody has implemented others.
- Line cap orientation as a connector's anchor (it's startpoint/endpoint) is moved resulted in some ugly code.
- Now that I think about it, I might have made it overly complicated!
- Connectors and background redraw:
- Fun stuff - in an effort to optimize the situation, a connector is comprised of separate line segments, and the whole shape rectangle is overridden so only the lines track their background, rather than the rectangle (which could be quite large!) of the entire shape.
- Anti-aliasing:
- I thought my PNG writer was not anti-aliasing until I realized that my viewer was auto-zooming the image to fit the display window!
- Object Oriented Programming:
- Object model refactoring: Shapes need to have a lot of control over their destiny. This required almost constant refactoring, and there were several throw-aways of bad design and the features were developed. I'm not sure this could have been avoided as a lot of the design was "learn as you go" with regards to the required functionality.
- Annoyingly, there are a handful of methods in a couple base classes that are just stubs for the sole purpose of "inversion of control" - allowing the derived class to do something special. Most of these have to do with how connectors handle some UI behaviors.
- Comparing pen colors:
- No, you can't do
Pen.Color == Color.Red
because the structures don't match, and the built-in operator==
doesn't work correctly. So it's pen.Color.ToArgb() == Color.Red.ToArgb()
if you want it to work correctly.
- Boundary conditions:
- Shapes partially or fully off screen
- Shapes with 0 or negative size
- Font size changes that exceed the boundaries of the shape (not handled)
- Font size changes that make the text shape grow beyond its original background (handled)
- Border line width (probably handled, not sure)
- Grid background:
- Cool, but performance is sub-optimal if you are dragging the entire surface and redrawing the entire window every time.
- Tradeoff (taken) is to just move the shapes. Grid is more just an aesthetic thing that looks cool.
- Did I mention background erasing?
- Shape overlap and z-ordering!
- This means detecting overlaps so all overlapping shapes can be erased top-to-bottom and redrawn bottom-to-top.
- Serialization:
- Shapes maintain a list of connector objects
- Connectors maintain who they are connected to
- You can't serialize these things directly, so each shape has an ID and everything has to be wired back up on deserialization. Fun stuff but actually not that complicated.
- Anchor - connection point snap:
- Cool feature, works great, but how do you disconnect an anchor?
- This is probably the most complex piece of code.
- Mouse events:
- Sigh. Why does a mouse click also fire a mouse move event with 0 change in location???
- And a dozen things I've forgotten about by now...
Because connectors are so complicated (at least I think they are at the moment) I:
- Require the user assists by selecting the right general connector shape.
- Don't route around shapes. Oh well.
- Connectors cannot connect to each other (if you enable this feature, you'll notice some weird behavior when dragging a connected connector.)
So that's another development practice: shove algorithm complexity onto the user experience and make the user figure it out! Instead, the user gets to pick the right connector for the job:
The three basic connectors can be oriented to make the desired connector. Hitler would have had fun with these.
Yes, this was created using FlowSharp!
And of course, what do I find after trying to load this diagram again, but a stupid bug (aren't most bugs stupid?) I was using copy and paste for the rectangles, and this code:
el.Id = Guid.NewGuid();
el.Deserialize(epb);
was assigning a new shape ID before the deserialization of the copied shape.
Which broke this code:
ToElement = elements.Single(e => e.Id == cpb.ToElementId);
Because more than one shape had the same ID! I'm not sure, even if I had written a unit test, I would have checked for unique ID, which just goes to show you (which we all know, right?) that passing unit tests do not guarantee a bug free application.
Here's another one that can't be unit tested. My original "move everything" code, which occurs when you drag the canvas itself, looked like this:
elements.ForEach(el =>
{
MoveElement(el, delta);
});
Not bad, but notice what MoveElement
does:
public void MoveElement(GraphicElement el, Point delta)
{
if (el.OnScreen())
{
int dx = delta.X.Abs();
int dy = delta.Y.Abs();
List<GraphicElement> els = EraseTopToBottom(el, dx, dy);
el.Move(delta);
el.UpdatePath();
DrawBottomToTop(els, dx, dy);
UpdateScreen(els, dx, dy);
}
else
{
el.CancelBackground();
el.Move(delta);
}
}
This is great for moving a single element -- it finds all the intersecting shapes, erases them, moves the desired shape, and then redraws them all. The edge case of the element being off screen is nicely handled too.
The problem with this code is that, if you're moving everything, this results in a really chunky movement. This code:
public void MoveAllElements(Point delta)
{
EraseTopToBottom(elements);
elements.ForEach(e =>
{
e.Move(delta);
e.UpdatePath();
});
int dx = delta.X.Abs();
int dy = delta.Y.Abs();
DrawBottomToTop(elements, dx, dy);
UpdateScreen(elements, dx, dy);
}
fixes the problem -- when dragging the canvas, the user now experiences a beautifully smooth operation! Again, this is something that you just don't discover with unit tests.
The code is separated into two projects:
FlowSharp
- the UI itself with the toolbox, canvas, and property grid FlowSharpLib
- all the pieces for drawing shapes on the canvas, including the canvas controller
(FlowSharpUI.cs)
protected void InitializeCanvas()
{
canvas = new Canvas();
canvas.Initialize(pnlCanvas);
}
protected void InitializeControllers()
{
canvasController = new CanvasController(canvas, elements);
canvasController.ElementSelected+=(snd, args) => UpdateMenu(args.Element != null);
toolboxController = new ToolboxController(toolboxCanvas, toolboxElements, canvasController);
uiController = new UIController(pgElement, canvasController);
}
Here, the canvas and canvas controller is initialized. The initial set of elements is empty (there's some commented out code for starting with some elements which was helpful for testing.) The UI hooks the ElementSelected
to update the menu, and the toolbox controller is also initialized.
(ToolboxText.cs)
In one instance, the Text
shape, I completely override the rendering of the default shape:
using System.Drawing;
namespace FlowSharpLib
{
public class ToolboxText : GraphicElement
{
public const string TOOLBOX_TEXT = "A";
protected Brush brush = new SolidBrush(Color.Black);
public ToolboxText(Canvas canvas) : base(canvas)
{
TextFont.Dispose();
TextFont = new Font(FontFamily.GenericSansSerif, 20);
}
public override GraphicElement Clone(Canvas canvas)
{
TextShape shape = new TextShape(canvas);
return shape;
}
public override void Draw(Graphics gr)
{
SizeF size = gr.MeasureString(TOOLBOX_TEXT, TextFont);
Point textpos = DisplayRectangle.Center().Move((int)(-size.Width / 2),
(int)(-size.Height / 2));
gr.DrawString(TOOLBOX_TEXT, TextFont, brush, textpos);
base.Draw(gr);
}
}
}
The difference is that in the toolbox
, the element is drawn with a big letter "A
", but the actual element defaults to a smaller font and the text "[enter text]".
(BaseController.cs, CanvasController.cs, ToolboxController.cs)
The canvas handles its background grid and calls the CanvasPaintComplete Action<>
method that the controller must set for the canvas. By default, all it does is this:
protected void CanvasPaintComplete(Canvas canvas)
{
DrawBottomToTop(elements);
}
(BaseController.cs)
protected IEnumerable<GraphicElement>
EraseTopToBottom(GraphicElement el, int dx = 0, int dy = 0)
{
List<GraphicElement> intersections = new List<GraphicElement>();
FindAllIntersections(intersections, el, dx, dy);
IEnumerable<GraphicElement> els = intersections.OrderBy(e => elements.IndexOf(e));
els.Where(e => e.OnScreen(dx, dy)).ForEach(e => e.Erase());
return els;
}
protected void EraseTopToBottom(IEnumerable<GraphicElement> els)
{
els.Where(e => e.OnScreen()).ForEach(e => e.Erase());
}
protected void DrawBottomToTop(IEnumerable<GraphicElement> els, int dx = 0, int dy = 0)
{
els.Reverse().Where(e => e.OnScreen(dx, dy)).ForEach(e =>
{
e.GetBackground();
e.Draw();
});
}
In order to optimize the drawing of a shape, any time it's moved, any intersecting elements also have to be erased. Erasing is done top-to-bottom, and redrawing is done bottom-to-top. This allows each shape to capture the background before it is drawn.
Intersections are detected like this:
protected void FindAllIntersections(List<GraphicElement> intersections,
GraphicElement el, int dx = 0, int dy = 0)
{
elements.Where(e => !intersections.Contains(e) &&
e.UpdateRectangle.IntersectsWith(el.UpdateRectangle.Grow(dx, dy))).ForEach((e) =>
{
intersections.Add(e);
FindAllIntersections(intersections, e);
});
}
A fun recursive algorithm! The optional "grow" factor handles the fact that after a shape has been moved, we have to identify intersections of both the source and destination locations. This algorithm assumes that movement is typically in small increments. Intersected shapes, which are not moving, have a default "grow" factor of 0.
(Canvas.cs)
The actual Panel
base class control is double-buffered, which creates a nice user experience:
public Canvas()
{
DoubleBuffered = true;
But FlowSharp maintains its own bitmap
:
public void CreateBitmap(int w, int h)
{
bitmap = new Bitmap(w, h);
CreateGraphicsObjects();
}
protected void CreateBitmap()
{
bitmap = new Bitmap(ClientSize.Width, ClientSize.Height);
CreateGraphicsObjects();
}
protected void CreateGraphicsObjects()
{
graphics = Graphics.FromImage(bitmap);
antiAliasGraphics = Graphics.FromImage(bitmap);
antiAliasGraphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
}
Note also that two Graphic
objects are created, as for some operations, we don't need the anti-aliasing feature.
Maintaining our own bitmap allows for erasing shapes:
(GraphicElement.cs)
public virtual void Erase()
{
if (canvas.OnScreen(backgroundRectangle))
{
background?.Erase(canvas, backgroundRectangle);
background = null;
}
}
public static void Erase(this Bitmap background, Canvas canvas, Rectangle r)
{
canvas.DrawImage(background, r);
background.Dispose();
}
and capturing the new background after the shape has moved:
public virtual void GetBackground()
{
background?.Dispose();
background = null;
backgroundRectangle = canvas.Clip(UpdateRectangle);
if (canvas.OnScreen(backgroundRectangle))
{
background = canvas.GetImage(backgroundRectangle);
}
}
(Canvas.cs)
public Bitmap GetImage(Rectangle r)
{
return bitmap.Clone(r, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
}
Updating the screen itself is simple:
(BaseController.cs)
protected void UpdateScreen(IEnumerable<GraphicElement> els, int dx = 0, int dy = 0)
{
els.Where(e => e.OnScreen(dx, dy)).ForEach(e => e.UpdateScreen(dx, dy));
}
Is this faster than creating a unioned rectangle? Dunno, because the unioned rectangle might include a lot of space not part of the shapes, for example like something in an "L" pattern.
(Canvas.cs)
public virtual void UpdateScreen(int ix = 0, int iy = 0)
{
Rectangle r = canvas.Clip(UpdateRectangle.Grow(ix, iy));
if (canvas.OnScreen(r))
{
canvas.CopyToScreen(r);
}
}
public void CopyToScreen(Rectangle r)
{
Bitmap b = bitmap.Clone(r, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
Graphics grScreen = CreateGraphics();
grScreen.DrawImage(b, r);
b.Dispose();
grScreen.Dispose();
}
Note how CopyToScreen
copies only the region of our internal bitmap to the affected region on the screen. The odd thing is that Graphics
has a CopyFromScreen
, but not a CopyToScreen
, so we have to write it ourselves.
(Canvas.cs)
Shapes partially off screen are clipped:
public Rectangle Clip(Rectangle r)
{
int x = r.X.Max(0);
int y = r.Y.Max(0);
int width = (r.X + r.Width).Min(bitmap.Width) - r.X;
int height = (r.Y + r.Height).Min(bitmap.Height) - r.Y;
width += r.X - x;
height += r.Y - y;
return new Rectangle(x, y, width, height);
}
(BaseController.cs)
public void MoveElement(GraphicElement el, Point delta)
{
if (el.OnScreen())
{
int dx = delta.X.Abs();
int dy = delta.Y.Abs();
var els = EraseTopToBottom(el, dx, dy);
el.Move(delta);
el.UpdatePath();
DrawBottomToTop(els, dx, dy);
UpdateScreen(els, dx, dy);
}
else
{
el.CancelBackground();
el.Move(delta);
}
}
Moving a shape involves:
- erasing it an its intersecting shapes
- updating the shape's path (implemented by connector shapes)
- redrawing the affected shapes on the bitmap
- and copying the bitmap regions to the screen.
(CanvasController.cs)
This is by far the most complicated code in the CanvasController
. Let's start with the piece that detects whether the anchor of a connector is near a connection point of a shape:
protected virtual List<SnapInfo>
GetNearbyElements(IEnumerable<ConnectionPoint> connectionPoints)
{
List<SnapInfo> nearElements = new List<SnapInfo>();
elements.Where(e=>e != selectedElement && e.OnScreen() && !e.IsConnector).ForEach(e =>
{
Rectangle checkRange = e.DisplayRectangle.Grow(SNAP_ELEMENT_RANGE);
connectionPoints.ForEach(cp =>
{
if (checkRange.Contains(cp.Point))
{
nearElements.Add(new SnapInfo() { NearElement = e, LineConnectionPoint = cp });
}
});
});
return nearElements;
}
This method returns all the possible snap points (the connection points) of the shape that the anchor is near to. It's not obvious that we're using the anchor because the parameter passed in is the connection points of the connector. Because those are the same as a connector's endpoints, we use the connector's connection points rather than going through hoops to figure out the actual point on the anchor that represents the "tip" of the connector.
The actual snap detection method is this monster:
public override bool Snap(GripType type, ref Point delta)
{
bool snapped = false;
IEnumerable<ConnectionPoint> connectionPoints = selectedElement.GetConnectionPoints().
Where(p => type == GripType.None || p.Type == type);
List<SnapInfo> nearElements = GetNearbyElements(connectionPoints);
ShowConnectionPoints(nearElements.Select(e=>e.NearElement), true);
ShowConnectionPoints(currentlyNear.
Where(e => !nearElements.
Any(e2 => e.NearElement == e2.NearElement)).
Select(e=>e.NearElement), false);
currentlyNear = nearElements;
foreach (SnapInfo si in nearElements)
{
ConnectionPoint nearConnectionPoint = si.NearElement.GetConnectionPoints().
FirstOrDefault(cp => cp.Point.IsNear
(si.LineConnectionPoint.Point, SNAP_CONNECTION_POINT_RANGE));
if (nearConnectionPoint != null)
{
Point sourceConnectionPoint = si.LineConnectionPoint.Point;
int neardx = nearConnectionPoint.Point.X -
sourceConnectionPoint.X;
int neardy = nearConnectionPoint.Point.Y - sourceConnectionPoint.Y;
int neardxsign = neardx.Sign();
int neardysign = neardy.Sign();
int deltaxsign = delta.X.Sign();
int deltaysign = delta.Y.Sign();
if ((neardxsign == 0 || deltaxsign == 0 || neardxsign == deltaxsign) &&
(neardysign == 0 || deltaysign == 0 || neardysign == deltaysign))
{
if (neardxsign == 0 && neardxsign == 0 &&
(delta.X.Abs() >= SNAP_DETACH_VELOCITY || delta.Y.Abs() >= SNAP_DETACH_VELOCITY))
{
selectedElement.DisconnectShapeFromConnector(type);
selectedElement.RemoveConnection(type);
}
else
{
if (neardxsign != 0 || neardysign != 0)
{
si.NearElement.Connections.Add(new Connection()
{
ToElement = selectedElement,
ToConnectionPoint = si.LineConnectionPoint,
ElementConnectionPoint = nearConnectionPoint
});
selectedElement.SetConnection(si.LineConnectionPoint.Type, si.NearElement);
}
delta = new Point(neardx, neardy);
snapped = true;
break;
}
}
}
}
return snapped;
}
The algorithm:
- Finds nearby shapes
- Checks each connection point on the shape to see if we're already connected or moving toward the connection point
- If already connected (
neardxsign
and neardysign
both == 0
), check if we're actually moving away at a sufficient "velocity" and if so, detach the connector. - Otherwise, check if we're already connected. If not, connect the connector to the shape.
The list of near shapes is preserved so that the connection points can be erased on a mouse-up:
protected void OnMouseUp(object sender, MouseEventArgs args)
{
if (args.Button == MouseButtons.Left)
{
selectedAnchor = null;
leftMouseDown = false;
dragging = false;
ShowConnectionPoints(currentlyNear.Select(e => e.NearElement), false);
currentlyNear.Clear();
}
}
Implementing the snap check is done in the mouse move event hander:
protected void OnMouseMove(object sender, MouseEventArgs args)
{
Point delta = args.Location.Delta(mousePosition);
if (delta == Point.Empty) return;
mousePosition = args.Location;
if (dragging)
{
if (selectedAnchor != null)
{
bool connectorAttached = selectedElement.SnapCheck(selectedAnchor, delta);
if (!connectorAttached)
{
selectedElement.DisconnectShapeFromConnector(selectedAnchor.Type);
selectedElement.RemoveConnection(selectedAnchor.Type);
}
}
else
{
DragSelectedElement(delta);
}
}
Notice in the Snap
method that the delta is a reference. The Snap
method updates this value when a snap occurs. In the SnapCheck
method, the element is either moved based on the user's mouse movement or by the amount the Snap
method determined is necessary to actually make the connection:
(GraphicElement.cs, DynamicConnector.cs, Line.cs)
public virtual bool SnapCheck(ShapeAnchor anchor, Point delta)
{
UpdateSize(anchor, delta);
canvas.Controller.UpdateSelectedElement.Fire
(this, new ElementEventArgs() { Element = this });
return false;
}
(The UpdateSelectedElement
event is fired so that the property grid can be updated with the new position information.)
We can also snap a connector by moving the entire connector (preserving its shape) rather than the connector's anchor point. This will snap either endpoint of the connector to a nearby shape's connection point, and is handled by the method responsible for dragging the shape:
(CanvasController.cs)
public void DragSelectedElement(Point delta)
{
bool connectorAttached = selectedElement.SnapCheck(GripType.Start, ref delta) ||
selectedElement.SnapCheck(GripType.End, ref delta);
selectedElement.Connections.ForEach
(c => c.ToElement.MoveElementOrAnchor(c.ToConnectionPoint.Type, delta));
MoveElement(selectedElement, delta);
UpdateSelectedElement.Fire(this, new ElementEventArgs() { Element = SelectedElement });
if (!connectorAttached)
{
DetachFromAllShapes(selectedElement);
}
}
Shapes cannot snap to each other, so a shape's default behavior is to return false
:
public virtual bool SnapCheck(GripType gt, ref Point delta) { return false; }
This is overridden by the DynamicConnector
:
(DynamicConnector.cs)
public override bool SnapCheck(GripType gt, ref Point delta)
{
return canvas.Controller.Snap(GripType.None, ref delta);
}
Horizontal and vertical lines are a bit stranger, because moving the line is constrained to the axis if the line -- you can't create diagonal lines. So for lines, if the line is snapped, we have to move the entire line, otherwise a diagonal line would be created. If there is no snap action, then the line is resized according to its constraints.
(Line.cs)
public override bool SnapCheck(ShapeAnchor anchor, Point delta)
{
bool ret = canvas.Controller.Snap(anchor.Type, ref delta);
if (ret)
{
Move(delta);
}
else
{
ret = base.SnapCheck(anchor, delta);
}
return ret;
}
(BaseController.cs)
public void SaveAsPng(string filename)
{
int x1 = elements.Min(e => e.DisplayRectangle.X);
int y1 = elements.Min(e => e.DisplayRectangle.Y);
int x2 = elements.Max(e => e.DisplayRectangle.X + e.DisplayRectangle.Width);
int y2 = elements.Max(e => e.DisplayRectangle.Y + e.DisplayRectangle.Height);
int w = x2 - x1 + 10;
int h = y2 - y1 + 10;
Canvas pngCanvas = new Canvas();
pngCanvas.CreateBitmap(w, h);
Graphics gr = pngCanvas.AntiAliasGraphics;
gr.Clear(Color.White);
Point offset = new Point(-(x1-5), -(y1-5));
Point restore = new Point(x1-5, y1-5);
elements.AsEnumerable().Reverse().ForEach(e =>
{
e.Move(offset);
e.UpdatePath();
e.SetCanvas(pngCanvas);
e.Draw(gr);
e.DrawText(gr);
e.SetCanvas(canvas);
e.Move(restore);
e.UpdatePath();
});
pngCanvas.Bitmap.Save(filename, System.Drawing.Imaging.ImageFormat.Png);
pngCanvas.Dispose();
}
Creating the PNG is bit "dirty" as all shapes need to be moved relative to the bitmap created for the PNG, which is sized to the extents of the shapes. And the canvas for each shape has to be set as well so that the shape thinks it's know attached to the PNG's canvas. Then, it all has to get undone. Personally, I think that this points to a "mild" design/implementation flaw, but the workaround in the code above is simple enough to get the job done for now.
(Persist.cs)
Serialization, at least at the call point, is straight forward:
public static string Serialize(List<GraphicElement> elements)
{
List<ElementPropertyBag> sps = new List<ElementPropertyBag>();
elements.ForEach(el =>
{
ElementPropertyBag epb = new ElementPropertyBag();
el.Serialize(epb);
sps.Add(epb);
});
XmlSerializer xs = new XmlSerializer(sps.GetType());
StringBuilder sb = new StringBuilder();
TextWriter tw = new StringWriter(sb);
xs.Serialize(tw, sps);
return sb.ToString();
}
Note the use of a separate property bag, which I use to keep a clean separate of concerns between the shapes, connectors, and internal stuff. The main purpose of the property bag is to handle serializing graphic objects, like fonts, colors, and pens, that .NET doesn't serialize:
For example:
[XmlIgnore]
public Color BorderPenColor { get; set; }
[XmlElement("BorderPenColor")]
public int XBorderPenColor
{
get { return BorderPenColor.ToArgb(); }
set { BorderPenColor = Color.FromArgb(value); }
}
The heavy lifting is handed off to the shapes, which has to deal with saving the actual attributes of a font, brush, pen, and color:
(GraphicElement.cs)
public virtual void Serialize(ElementPropertyBag epb)
{
epb.ElementName = GetType().AssemblyQualifiedName;
epb.Id = Id;
epb.DisplayRectangle = DisplayRectangle;
epb.BorderPenColor = BorderPen.Color;
epb.BorderPenWidth = (int)BorderPen.Width;
epb.FillBrushColor = FillBrush.Color;
epb.Text = Text;
epb.TextColor = TextColor;
epb.TextFontFamily = TextFont.FontFamily.Name;
epb.TextFontSize = TextFont.Size;
epb.TextFontUnderline = TextFont.Underline;
epb.TextFontStrikeout = TextFont.Strikeout;
epb.TextFontItalic = TextFont.Italic;
epb.HasCornerAnchors = HasCornerAnchors;
epb.HasCenterAnchors = HasCenterAnchors;
epb.HasLeftRightAnchors = HasLeftRightAnchors;
epb.HasTopBottomAnchors = HasTopBottomAnchors;
epb.HasCornerConnections = HasCornerConnections;
epb.HasCenterConnections = HasCenterConnections;
epb.HasLeftRightConnections = HasLeftRightConnections;
epb.HasTopBottomConnections = HasTopBottomConnections;
Connections.ForEach(c => c.Serialize(epb));
}
Yes, it's a little annoying to have to copy the properties that need serialization into the property bag, but I really do like the separation of a serialization model from the shape model.
(Persist.cs)
Deserialization is more complicated because the connection points need to be wired up to their actual objects:
public static List<GraphicElement> Deserialize(Canvas canvas, string data)
{
Tuple<List<GraphicElement>,
List<ElementPropertyBag>> collections = InternalDeserialize(canvas, data);
FixupConnections(collections);
FinalFixup(collections);
return collections.Item1;
}
private static Tuple<List<GraphicElement>, List<ElementPropertyBag>>
InternalDeserialize(Canvas canvas, string data)
{
List<GraphicElement> elements = new List<GraphicElement>();
XmlSerializer xs = new XmlSerializer(typeof(List<ElementPropertyBag>));
TextReader tr = new StringReader(data);
List<ElementPropertyBag> sps = (List<ElementPropertyBag>)xs.Deserialize(tr);
foreach (ElementPropertyBag epb in sps)
{
Type t = Type.GetType(epb.ElementName);
GraphicElement el = (GraphicElement)Activator.CreateInstance(t, new object[] { canvas });
el.Deserialize(epb);
elements.Add(el);
epb.Element = el;
}
return new Tuple<List<GraphicElement>, List<ElementPropertyBag>>(elements, sps);
}
private static void FixupConnections(Tuple<List<GraphicElement>,
List<ElementPropertyBag>> collections)
{
foreach (ElementPropertyBag epb in collections.Item2)
{
epb.Connections.Where(c => c.ToElementId != Guid.Empty).ForEach(c =>
{
Connection conn = new Connection();
conn.Deserialize(collections.Item1, c);
epb.Element.Connections.Add(conn);
});
}
}
private static void FinalFixup(Tuple<List<GraphicElement>,
List<ElementPropertyBag>> collections)
{
collections.Item2.ForEach(epb => epb.Element.FinalFixup(collections.Item1, epb));
}
Connectors implement serialization/deserialization for the data that they manage:
(Connection.cs)
public class Connection
{
public GraphicElement ToElement { get; set; }
public ConnectionPoint ToConnectionPoint { get; set; }
public ConnectionPoint ElementConnectionPoint { get; set; }
public void Serialize(ElementPropertyBag epb)
{
ConnectionPropertyBag cpb = new ConnectionPropertyBag();
cpb.ToElementId = ToElement.Id;
cpb.ToConnectionPoint = ToConnectionPoint;
cpb.ElementConnectionPoint = ElementConnectionPoint;
epb.Connections.Add(cpb);
}
public void Deserialize(List<GraphicElement> elements, ConnectionPropertyBag cpb)
{
ToElement = elements.Single(e => e.Id == cpb.ToElementId);
ToConnectionPoint = cpb.ToConnectionPoint;
ElementConnectionPoint = cpb.ElementConnectionPoint;
}
}
and lastly, when the connection objects are all deserialized, a final fixup is required to wire up the actual shape object from the shape ID:
(GraphicElement.cs, Connector.cs)
public override void FinalFixup(List<GraphicElement> elements, ElementPropertyBag epb)
{
base.FinalFixup(elements, epb);
StartConnectedShape = elements.SingleOrDefault(e => e.Id == epb.StartConnectedShapeId);
EndConnectedShape = elements.SingleOrDefault(e => e.Id == epb.EndConnectedShapeId);
}
(FlowSharpUI.cs)
Because there's no actual input control, keyboard operations in the FlowSharp UI project have to be intercepted by overriding ProcessCmdKey
:
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
Action act;
bool ret = false;
if (canvas.Focused && canvasController.SelectedElement != null
&& keyActions.TryGetValue(keyData, out act))
{
act();
ret = true;
}
else
{
ret = base.ProcessCmdKey(ref msg, keyData);
}
return ret;
}
A dictionary
:
protected Dictionary<Keys, Action> keyActions = new Dictionary<Keys, Action>();
is initialized with keyboard actions that can be performed on a selected shape:
keyActions[Keys.Control | Keys.C] = Copy;
keyActions[Keys.Control | Keys.V] = Paste;
keyActions[Keys.Delete] = Delete;
keyActions[Keys.Up] = () => canvasController.DragSelectedElement(new Point(0, -1));
keyActions[Keys.Down] = () => canvasController.DragSelectedElement(new Point(0, 1));
keyActions[Keys.Left] = () => canvasController.DragSelectedElement(new Point(-1, 0));
keyActions[Keys.Right] = () => canvasController.DragSelectedElement(new Point(1, 0));
Note that DragSelectedElement
also does a snap check. Using the keyboard's "down
" to move a connector:
Not in snap range In snap range closer.... snapped!
You cannot detach a connector using the keyboard (the "velocity" is too small.) Is that a bug?
These are very primitive shapes that are constrained to be sized only along their axis (see Anchor Constraints). You cannot create a diagonal line. A line is drawn based on the DisplayRectangle
of the shape, for example:
(VerticalLine.cs)
public override void Draw(Graphics gr)
{
Pen pen = (Pen)BorderPen.Clone();
if (ShowLineAsSelected)
{
pen.Color = pen.Color.ToArgb() == Color.Red.ToArgb() ? Color.Blue : Color.Red;
}
gr.DrawLine(pen, DisplayRectangle.TopMiddle(), DisplayRectangle.BottomMiddle());
pen.Dispose();
base.Draw(gr);
}
The movement of an anchor is constrained by the type of anchor that you select:
(ShapeAnchor.cs)
public Point AdjustedDelta(Point delta)
{
Point ad = Point.Empty;
switch (Type)
{
case GripType.TopLeft:
case GripType.TopRight:
case GripType.BottomLeft:
case GripType.BottomRight:
case GripType.Start:
case GripType.End:
ad = delta;
break;
case GripType.LeftMiddle:
ad = new Point(delta.X, 0);
break;
case GripType.RightMiddle:
ad = new Point(delta.X, 0);
break;
case GripType.TopMiddle:
ad = new Point(0, delta.Y);
break;
case GripType.BottomMiddle:
ad = new Point(0, delta.Y);
break;
}
return ad;
}
As the above code shows, corner anchors and dynamic connector Start
and End
grip types are not constrained. Middle anchors are constrained.
For sanity, shapes cannot be less than a minimum width and height. This means you cannot invert a shape by moving the top-left corner of a shape below or to the right of the bottom or right edge.
This illustrates the minimum sizes of a square (with mouse hover to show the anchor points) and a circle. The reason for these minimums is to still show (as the left shape illustrates) the anchor points. This is an ugly and buggy piece of code that I won't show.
Line caps for dynamic connectors are complicated. In fact, drawing the lines for a dynamic connector is complicated. Here's why. First, let's look at what happens with the default endcap behavior for a line with negative width. We'll start with a left-right dynamic connector:
and move the start point so that the width is negative (start X > end X):
If we account for negative widths, what happens is this:
Oops. The endcap draws beyond the actual end of the line.
Let's see what happens when we adjust the endcap:
AdjustableArrowCap adjCap = new AdjustableArrowCap
(-BaseController.CAP_WIDTH, BaseController.CAP_HEIGHT, true);
note the negative width. Now we get:
Notice we still have the same problem, but now there's artifact in the endcap -- a white line!
Fussing with endcap properties, I did figure out how to draw diamond endcaps:
adjCap.MiddleInset = -5;
That particular discovery allowed me to add diamonds to the list of possible endcaps.
Back to the point--the way .NET draws endcaps for lines in the "negative" orientation appears to bugged (I don't discount the possibility that my tests had a bug though!) That means we have to re-orient the lines and the start/endcaps depending on the line orientation. For a right-angle connector, the horizontal and vertical lines must be re-oriented so they always draw right-to-left and top-to-bottom:
if (startPoint.X < endPoint.X)
{
lines[0].DisplayRectangle = new Rectangle(startPoint.X,
startPoint.Y - BaseController.MIN_HEIGHT / 2,
endPoint.X - startPoint.X, BaseController.MIN_HEIGHT);
}
else
{
lines[0].DisplayRectangle = new Rectangle(endPoint.X,
startPoint.Y - BaseController.MIN_HEIGHT / 2,
startPoint.X - endPoint.X, BaseController.MIN_HEIGHT);
}
if (startPoint.Y < endPoint.Y)
{
lines[1].DisplayRectangle = new Rectangle(endPoint.X - BaseController.MIN_WIDTH / 2,
startPoint.Y, BaseController.MIN_WIDTH, endPoint.Y - startPoint.Y);
}
else
{
lines[1].DisplayRectangle = new Rectangle(endPoint.X - BaseController.MIN_WIDTH / 2,
endPoint.Y, BaseController.MIN_WIDTH, startPoint.Y - endPoint.Y);
}
and of course, the endcaps need to be re-oriented because we've just changed the direction of line:
protected void UpdateCaps()
{
if (startPoint.X < endPoint.X)
{
lines[0].StartCap = StartCap;
lines[0].EndCap = AvailableLineCap.None;
}
else
{
lines[0].StartCap = AvailableLineCap.None;
lines[0].EndCap = StartCap;
}
if (startPoint.Y < endPoint.Y)
{
lines[1].StartCap = AvailableLineCap.None;
lines[1].EndCap = EndCap;
}
else
{
lines[1].StartCap = EndCap;
lines[1].EndCap = AvailableLineCap.None;
}
lines.ForEach(l => l.UpdateProperties());
}
Conversely, and here's the real clincher, even if I'm wrong about how endcaps work with negative orientations, the fact that the display rectangle could have a negative width blows the Clone
method (and others) out of the water (it throws an exception):
public Bitmap GetImage(Rectangle r)
{
return bitmap.Clone(r, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
}
So, even if I'm wrong about the .NET endcap bug, .NET still doesn't like negative orientations for certain operations, and in fact, my library is very much oriented around the idea that the upper left corner of a rectangle is above and to the left of the bottom right corner, meaning the width and height are expected to be > 0. It's not too hard to fix that, but it does require additional computations especially when sizing a shape that is "inverted" in orientation. I didn't really want to go there, so I'm paying the "penalty" for that in how the dynamic connector works. This is one of those tradeoff cases -- the code as-is works. The complexity increase in all the other parts of the code (including working around .NET exceptions) in order to simplify dynamic connector drawing doesn't seem justified.
The UI is very simple, consisting of a toolbox panel, the canvas, and a property grid. You'll notice I changed the toolbox layout a bit and added some triangle shapes from the previous screenshots.
The toolbox illustrates how easy it is to create your own canvas and controller.
This is about as simple as it gets:
(ToolboxCanvas.cs)
public class ToolboxCanvas : Canvas
{
protected override void DrawBackground(Graphics gr)
{
gr.Clear(Color.LightGray);
}
protected override void DrawGrid(Graphics gr)
{
}
}
Here, the default canvas behavior is overridden:
- The background is set to light grey.
- The grid is not drawn.
The controller is very simple -- click on a shape in the toolbox and it appears on the canvas as a selected shape. Ideally, I'd like to drag the toolbox shape to the canvas, as that is the typical process after selecting the shape: dragging it somewhere. By dragging the toolbox shape, additional mouse movement and a mouse click would be avoided, however I haven't implemented that yet. So the controller is very simple right now:
(ToolboxController.cs)
public class ToolboxController : BaseController
{
protected CanvasController canvasController;
public ToolboxController(Canvas canvas, List<GraphicElement> elements,
CanvasController canvasController) :
base(canvas, elements)
{
this.canvasController = canvasController;
canvas.PaintComplete = CanvasPaintComplete;
canvas.MouseDown += OnMouseDown;
}
public void OnMouseDown(object sender, MouseEventArgs args)
{
if (args.Button == MouseButtons.Left)
{
selectedElement = SelectElement(args.Location);
if (selectedElement != null)
{
GraphicElement el = selectedElement.CloneDefault(canvasController.Canvas);
canvasController.Insert(el);
canvasController.SelectElement(el);
}
}
}
protected GraphicElement SelectElement(Point p)
{
GraphicElement el = elements.FirstOrDefault(e => e.DisplayRectangle.Contains(p));
return el;
}
}
Notice that the cloning of the shape and its default dimensions are left to each shape to determine. Cloning a shape is very easy:
(GraphicElement.cs)
public virtual GraphicElement CloneDefault(Canvas canvas)
{
GraphicElement el = (GraphicElement)Activator.CreateInstance(GetType(),
new object[] { canvas });
el.DisplayRectangle = el.DefaultRectangle();
el.UpdateProperties();
el.UpdatePath();
return el;
}
All this does is create a default shape from whatever the shape defines as its defaults. Contrast this with pasting, which serializes all shape properties and then deserializes them, inserting the new element slightly down and to the right of the current element, then selecting it:
(FlowSharpUI.cs)
GraphicElement el = Persist.DeserializeElement(canvas, copyBuffer);
el.Move(new Point(20, 20));
el.UpdateProperties();
el.UpdatePath();
canvasController.Insert(el);
canvasController.DeselectCurrentSelectedElement();
canvasController.SelectElement(el);
Not only was this fun to write, but I now have a usable and extensible diagramming tool. This is important because I want to prototype some other concepts where a diagramming tool is important, and I don't want to integrate Visio and the other open source diagramming tools I looked at didn't meet my needs.
It was also interesting writing this article, as there was actually a substantial amount of code cleanup / refactoring that took place as, while writing this, I realized, wow, this can be done better.
See the GitHub Issues page.
FlowSharp has undergone significant refactoring to implement a more service-oriented architecture. As the above diagram illustrates, the various core components of FlowSharp have been separated into distinct services:
FlowSharpService
- Interfaces with the docking manager to support management of canvases and docking events. FlowSharpCanvasService
- Handles all canvas related operations. FlowSharpEditService
- Handles all operations related to editing shapes: copy, cut, paste, shape text, etc. FlowSharpMouseControllerService
- Handles actions related to mouse activity. FlowSharpDebugWindowService
- Handles the debug window notifications and user actions. FlowSharpMenuService
- Handles actions related to menu selection. FlowSharpPropertyGridService
- Handles the property grid. FlowSharpToolboxService
- Handles the toolbox.
This architecture utilizes the SOA approach that I wrote about in The Clifton Method - Part II: Service Manager. The primary reason for refactoring the code is so that FlowSharp becomes extensible without touching the code base. The plug-in modules to support FlowSharpCode is an excellent example of how FlowSharp can be extended for new behavior.
The core FlowSharp application is now defined by the services in modules.xml:
<Modules>
<Module AssemblyName='Clifton.SemanticProcessorService.dll'/>
<Module AssemblyName='Clifton.DockingFormService.dll'/>
<Module AssemblyName='FlowSharpService.dll'/>
<Module AssemblyName='FlowSharpCanvasService.dll'/>
<Module AssemblyName='FlowSharpToolboxService.dll'/>
<Module AssemblyName='FlowSharpMouseControllerService.dll'/>
<Module AssemblyName='FlowSharpPropertyGridService.dll'/>
<Module AssemblyName='FlowSharpMenuService.dll'/>
<Module AssemblyName='FlowSharpEditService.dll'/>
<Module AssemblyName='FlowSharpDebugWindowService.dll'/>
</Modules>
Please note that FlowSharpCode is still in the prototype stage!
Previously, I wrote about the ability to use FlowSharp as an IDE for code development in the article V.A.P.O.R.ware - Visual Assisted Programming / Organizational Representation. At the time that I wrote that article, the code base there was a completely separate application. The various code pieces to support FlowSharpCode are now implemented as services (see above diagram):
FlowSharpCodeService
- Handles the association of code with shapes. FlowSharpCodeCompilerService
- Provides compiler services for shape code-behind and code generation. FlowSharpCodeICSharpDevelopService
- The code editor uses the Avalon WPF editor used by ICSharpCode.
To "activate" the FlowSharpCode
features, the module FlowSharpCodeModules.xml is specified as a parameter to FlowSharp.exe. This file pulls in the additional services:
<Modules>
<Module AssemblyName='Clifton.SemanticProcessorService.dll'/>
<Module AssemblyName='Clifton.DockingFormService.dll'/>
<Module AssemblyName='FlowSharpService.dll'/>
<Module AssemblyName='FlowSharpCanvasService.dll'/>
<Module AssemblyName='FlowSharpToolboxService.dll'/>
<Module AssemblyName='FlowSharpMouseControllerService.dll'/>
<Module AssemblyName='FlowSharpPropertyGridService.dll'/>
<Module AssemblyName='FlowSharpMenuService.dll'/>
<Module AssemblyName='FlowSharpEditService.dll'/>
<Module AssemblyName='FlowSharpDebugWindowService.dll'/>
<Module AssemblyName='FlowSharpCodeService.dll'/>
<Module AssemblyName='FlowSharpCodeCompilerService.dll'/>
<Module AssemblyName='FlowSharpCodeICSharpDevelopService.dll'/>
</Modules>
When using this file, the application includes an editor panel and adds a "Build" menu:
Service Basics - Initialization
Each service implements IModule
, which registers a singleton service. For example, here is how the Menu Service is implemented:
public class FlowSharpMenuModule : IModule
{
public void InitializeServices(IServiceManager serviceManager)
{
serviceManager.RegisterSingleton<IFlowSharpMenuService, FlowSharpMenuService>();
}
}
public class FlowSharpMenuService : ServiceBase, IFlowSharpMenuService
{
... Implementation if IFlowSharMenuService ...
}
The base class ServiceBase
is always initialized with an instance of the service manager:
public abstract class ServiceBase : IService
{
protected ServiceBase();
public IServiceManager ServiceManager { get; set; }
public virtual void FinishedInitialization();
public virtual void Initialize(IServiceManager svcMgr);
}
so once the service is instantiated, any method in the service can access other services. A very common thing to do is to get the active canvas controller:
BaseController canvasController = serviceManager.Get<IFlowSharpCanvasService>().ActiveController;
Refer to the assembly FlowSharpServiceInterfaces
, specifically interfaces.cs, to see what methods are exposed in each service.
A slightly different screenshot as compared to the one at the top of the article:
Docking is implement using DockPanelSuite and is managed by the Clifton.DockingFormService
asembly (the source code for which is found in the Clifton GitHub repo.) There's a few rough edges to be worked out (having nothing to do with the excellent DockPanelSuite
), however, things like persisting the layout configuration and saving (and loading!) all the documents associated with a diagram file appears to be working -- not without my having lost some work as bugs were discovered!
Shapes can now be bookmarked. A bookmarked shape is indicated by a small green square at the top left of the shape's display rectangle. Pressing Ctrl+K or "Go To Bookmark" from the menu brings up a dialog from which you can select a bookmarked shape:
All shapes can also be navigated to using Ctrl+H or "Go To Shape" from the menu, whether they are bookmarked or not. When you select a shape, it is "focused" to the center of the canvas.
Another useful feature, especially with FlowSharpCode, is the ability to navigate the selection history using Ctrl-Tab.
11/22/2016
Undo/Redo
Implementing undo/redo required a significant refactoring of the code responsible for moving shapes, anchors, and performing snap activity. There is now a completely separate controller, the SnapController
, that is responsible for managing the snap behavior. As the above screenshot shows:
- The debug window lets you inspect the undo buffer
- The edit menu exposes the undo/redo functions
Special thanks go to CPian Qwertie for sharing the code that manages the undo / redo stack.
The undo/redo stack operates on the principle of passing in an actions for the do (and redo) operation as well as the undo operation. A straight forward example is selecting a shape:
(MouseController.cs)
protected void AddShapeToSelectionList()
{
List<GraphicElement> selectedShapes = Controller.SelectedElements.ToList();
GraphicElement el = Controller.GetRootShapeAt(CurrentMousePosition);
Controller.UndoStack.UndoRedo("Select " + el.ToString(),
() =>
{
Controller.DeselectGroupedElements();
Controller.SelectElement(el);
justAddedShape.Add(el);
},
() =>
{
Controller.DeselectCurrentSelectedElements();
Controller.SelectElements(selectedShapes);
});
}
Typically, you pass in both do/redo and undo actions. These must be 100% symmetrical -- the undo must leave the application in exactly the state it was in before the action, and the redo must leave the application in exactly the same state as after the action.
One of the nifty features of Qwertie's UndoStack
is the ability to specify a do/redo/undo group:
In the debug window, a do/undo ("do
" also implies "redo
", so I'll avoid explicitly stating that from now on), a group is designated by the letter "F
". In the above screenshot, the actions "Attach
" and "ShapeMove
" are a group (as well as "Attach
" and "AnchorMove
") -- the movement of a shape or anchor that resulted in a connector attaching to a shape. This is a really useful feature for undoing/redoing associated actions, particularly connector moves that result in attaching/detaching to/from a shape.
Under certain conditions, managing undo/redo operations gets rather complicated. Consider moving a shape. It is natural to expect that all the mouse moves, starting from when the user clicks the shape, to when the user, after dragging the shape, releases the button -- all those mouse moves are actually accumulated into a single move action. Easier said than done, as this can also include attach/detach/re-attach/re-detach operations:
(MouseController.cs)
protected void DragShapes()
{
Controller.Canvas.Cursor = Cursors.SizeAll;
Point delta = CurrentMousePosition.Delta(LastMousePosition);
if (Controller.SelectedElements.Count == 1 && Controller.SelectedElements[0].IsConnector)
{
if (!Controller.SnapController.SnapCheck
(GripType.Start, delta, (snapDelta) => Controller.DragSelectedElements(snapDelta)))
{
if (!Controller.SnapController.SnapCheck
(GripType.End, delta, (snapDelta) => Controller.DragSelectedElements(snapDelta)))
{
Controller.DragSelectedElements(delta);
Controller.SnapController.UpdateRunningDelta(delta);
}
}
}
else
{
Controller.DragSelectedElements(delta);
Controller.SnapController.UpdateRunningDelta(delta);
}
}
Notice that there are no do/undo calls made here! Instead, the SnapController
is used to manage a running delta of all mouse movements. Furthermore, the SnapController
manages a list of "detach from one connection point and attach to a different connection point" actions:
protected void SetCurrentAction(SnapAction action)
{
if (currentSnapAction == null)
{
currentSnapAction = action;
}
else
{
if (action.TargetShape != currentSnapAction.TargetShape
|| action.GripType != currentSnapAction.GripType
|| action.ShapeConnectionPoint != currentSnapAction.ShapeConnectionPoint)
{
snapActions.Add(currentSnapAction);
currentSnapAction = action;
}
else
{
currentSnapAction = null;
}
}
}
It is only when the mouse button is released that all the snap actions and accumulated mouse movements are placed into the undo stack. But notice! In this case, there is no "Do
" action because all the actions have already been performed. Instead, there is a separate "redo
" action, which "normally" would be the same as the "do
" action, but in this case has to be managed separately:
(MouseController.cs)
router.Add(new MouseRouter()
{
RouteName = RouteName.EndShapeDrag,
MouseEvent = MouseEvent.MouseUp,
Condition = () => DraggingShapes,
Action = (_) =>
{
Controller.SnapController.DoUndoSnapActions(Controller.UndoStack);
if (Controller.SnapController.RunningDelta != Point.Empty)
{
Point delta = Controller.SnapController.RunningDelta;
Controller.UndoStack.UndoRedo("ShapeMove",
() => { },
() =>
{
Controller.DragSelectedElements(delta.ReverseDirection());
},
true,
() =>
{
Controller.DragSelectedElements(delta);
});
}
Controller.SnapController.HideConnectionPoints();
Controller.SnapController.Reset();
DraggingShapes = false;
DraggingAnchor = false;
SelectedAnchor = null;
Controller.Canvas.Cursor = Cursors.Arrow;
}
});
The SnapController
has a method for queuing up the do/undo actions for the attaching/detaching operation:
(SnapController.cs)
protected void DoUndoSnapAction(UndoStack undoStack, SnapAction action)
{
SnapAction closureAction = action.Clone();
if (closureAction.SnapType == SnapAction.Action.Attach)
{
undoStack.UndoRedo("Attach",
() => closureAction.Attach(),
() => closureAction.Detach(),
false);
}
else
{
undoStack.UndoRedo("Detach",
() => closureAction.Detach(),
() => closureAction.Attach(),
false);
}
}
Notice that the false
indicates that these actions are part of a group -- they are always associated with the user moving a connector or a connector's anchor, so they are always grouped with that activity. Also notice how closures are used to preserve the state of the action.
A helper class, SnapAction
, maintains everything necessary to attach or detach a connector to/from a shape:
(SnapController.cs)
public class SnapAction
{
public enum Action
{
Attached,
Attach,
Detach,
}
public Point Delta { get; protected set; }
public Action SnapType { get; protected set; }
public GraphicElement Connector { get { return connector; } }
public GraphicElement TargetShape { get { return targetShape; } }
public GripType GripType { get { return gripType; } }
public ConnectionPoint ShapeConnectionPoint { get { return shapeConnectionPoint; } }
protected GraphicElement connector;
protected GraphicElement targetShape;
protected GripType gripType;
protected ConnectionPoint lineConnectionPoint;
protected ConnectionPoint shapeConnectionPoint;
public SnapAction()
{
SnapType = Action.Attached;
}
public SnapAction(Action action, GraphicElement lineShape, GripType gripType,
GraphicElement targetShape, ConnectionPoint lineConnectionPoint,
ConnectionPoint shapeConnectionPoint, Point delta)
{
SnapType = action;
this.connector = lineShape;
this.gripType = gripType;
this.targetShape = targetShape;
this.lineConnectionPoint = lineConnectionPoint;
this.shapeConnectionPoint = shapeConnectionPoint;
Delta = delta;
}
public void Attach()
{
targetShape.Connections.Add(new Connection()
{
ToElement = connector,
ToConnectionPoint = lineConnectionPoint,
ElementConnectionPoint = shapeConnectionPoint
});
connector.SetConnection(lineConnectionPoint.Type, targetShape);
}
public void Detach()
{
connector.DisconnectShapeFromConnector(gripType);
connector.RemoveConnection(gripType);
}
public SnapAction Clone()
{
SnapAction ret = new SnapAction();
ret.SnapType = SnapType;
ret.connector = connector;
ret.gripType = gripType;
ret.targetShape = targetShape;
ret.lineConnectionPoint = lineConnectionPoint;
ret.shapeConnectionPoint = shapeConnectionPoint;
ret.Delta = Delta;
return ret;
}
}
Lessons Learned
The reason that the code for shape/anchor movement and snap checking had to be refactored is that originally, the snap check was buried inside connector shape and connector anchor move operations. When implementing undo/redo, it is critical that actions are not entangled -- they need to be discrete activities that are managed by the top level user event handlers -- in this case, keyboard and mouse events, which is why the code examples above show how the UndoStack
is utilized in the MouseController
class.
If I had started implementing FlowSharp with undo/redo in mind, I probably could have saved myself a lot of grief, as well as some ugly code that, after implementing actions as discrete activities, was eliminated. So I cannot over-emphasize:
- Methods should do one thing and one thing only
- Do not entangle orthogonal activities
After a time, you may find that having is not so pleasing a thing after all as wanting - Star Trek, Amok Time
And most importantly:
- Do not mix "can I do something" with "do something if I can" - the determination of whether you can do something, and doing that thing, are two separate things.
Besides creating hidden side-effects which are difficult to debug, it also makes implementing certain behaviors like undo/redo a nightmare which will force you, if you want to do it right, rewriting potentially vast sections of the code. Fortunately, in my case, the rewrite was minimal and I was able to re-use the much of the critical "activity" code and throw out much of the "control logic" code because the separation of activities was now much cleaner.
One of the keys to a successful detangling of activities is, instead of immediately performing an action, instead return a packet of data containing everything needed to "do" (and "undo") the action. Compare this truncated fragment of the new code:
(SnapController.cs)
if...
{
action = new SnapAction(SnapAction.Action.Detach,
selectedElement,
type,
si.NearElement,
si.LineConnectionPoint,
nearConnectionPoint,
delta);
break;
}
else
{
action = new SnapAction(SnapAction.Action.Attach,
selectedElement,
type,
si.NearElement,
si.LineConnectionPoint,
nearConnectionPoint,
new Point(neardx, neardy));
}
...
return action;
with the old code:
if...
{
el.DisconnectShapeFromConnector(gripType);
el.RemoveConnection(gripType);
}
else
{
si.NearElement.Connections.Add(
new Connection()
{
ToElement = selectedElement,
ToConnectionPoint = si.LineConnectionPoint,
ElementConnectionPoint = nearConnectionPoint
});
selectedElement.SetConnection(si.LineConnectionPoint.Type, si.NearElement);
}
While that looks reasonable enough, the old code was not just determining whether a snap could take place, it was performing the attach/detach. This error in mixing "can I do this" with "and if I can, do it" was ultimately the source of much gnashing of teeth. Notice with the new code, what is returned is what is needed for doing/undoing the activity, in addition to the state information of whether the snap action is possible. This exposes the activity such that the top level (the UI event handler) can determine where/when the action is to actually take place.
11/06/2016
Diagonal Connector
Diagonal connectors are now supported. The most interesting part of this code is handling the question "is the mouse near the diagonal connector?"
(DiagonalConnector.cs)
public override bool IsSelectable(Point p)
{
bool ret = false;
if (UpdateRectangle.Contains(p))
{
int a = p.X - UpdateRectangle.X;
int b = p.Y - UpdateRectangle.Y;
int c = UpdateRectangle.Width;
int d = UpdateRectangle.Height;
int dist = (int)(Math.Abs(a * d - c * b) / Math.Sqrt(c * c + d * d));
ret = dist <= BaseController.MIN_HEIGHT;
}
return ret;
}
Here, once the mouse moves into the rectangle defined by the diagonal connector, an additional test of how close the mouse is to the line is made to qualify whether the connector can be selected.
Plug-In Support
As I want to extend the kinds of shapes (and their behaviors) from the core set, a plug-in ability was definitely needed. This is demonstrated with the ImageShape
plug-in:
Yes, you get stuck with a picture of my cat, Earl Grey, unless you change the bitmap in the resource file.
Plug-ins are implemented very "cheaply" at the moment. There is a silly little dialog where you list, on separate lines, the assemblies that contain your additional shapes:
Each assembly in the list is inspected for classes that derive from GraphicElement
:
(PluginManager.cs)
protected void RegisterPlugin(string plugin)
{
try
{
Assembly assy = Assembly.LoadFrom(plugin);
pluginAssemblies.Add(assy);
assy.GetTypes().ForEach(t =>
{
if (t.IsSubclassOf(typeof(GraphicElement)))
{
pluginShapes.Add(t);
}
});
pluginFiles.Add(plugin);
}
catch (Exception ex)
{
MessageBox.Show(plugin + "\r\n" + ex.Message,
"Plugin Load Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
Once located, plug-ins are added automatically to the toolbar:
(FlowSharpUI.cs)
protected void InitializePluginsInToolbox()
{
int x = pnlToolbox.Width / 2 - 12;
List<Type> pluginShapes = pluginManager.GetShapeTypes();
int n = x - 60;
int y = 260;
foreach (Type t in pluginShapes)
{
GraphicElement pluginShape = Activator.CreateInstance
(t, new object[] { toolboxCanvas }) as GraphicElement;
pluginShape.DisplayRectangle = new Rectangle(n, y, 25, 25);
toolboxElements.Add(pluginShape);
n += 40;
if (n > x + 60)
{
n = x - 60;
y += 40;
}
}
}
As you can see, the implementation is very basic right now -- I don't even check if the number of plug-ins exceeds the height of the toolbox!
Creating a shape plug-in is very simple:
- Create a DLL project
- Reference
FlowSharpLib
- Derive your shape from
GraphicElement
- Implement any overrides for drawing the shape and performing other actions, typically extra information that needs to be serialized / deserialized.
The ImageShape
The ImageShape
used a plug-in example overrides drawing and serialization / deserialization:
(ImageShape.cs)
public override void Serialize(ElementPropertyBag epb,
List<GraphicElement> elementsBeingSerialized)
{
epb.ExtraData = Filename;
base.Serialize(epb, elementsBeingSerialized);
}
public override void Deserialize(ElementPropertyBag epb)
{
Filename = epb.ExtraData;
base.Deserialize(epb);
}
public override void Draw(Graphics gr)
{
if (image == null)
{
gr.FillRectangle(FillBrush, DisplayRectangle);
}
else
{
gr.DrawImage(image, DisplayRectangle,
new Rectangle(0, 0, image.Width, image.Height), GraphicsUnit.Pixel);
}
gr.DrawRectangle(BorderPen, DisplayRectangle);
base.Draw(gr);
}
At the moment, no attempt to maintain aspect ration is supported and probably in the next update, I'll have moved this shape into the core library.
10/23/2016
Grouping
First off, as the above screenshot illustrates, the debug view now shows the hierarchy of grouped shapes. You can also click on an element in the debug view and the selected element is indicated on the surface (in this case, the diamond with the blue rectangle around it.)
Secondly, you'll notice that groups can be nested. I point this out mainly because it was not as easy to implement as one would think, with regards to moving a group in the z-order up and down.
Implementing grouping touched on just about every aspect of the FlowSharpLib code. These were some of the issues and areas of the code that had to be touched:
- When grouping, deselect grouped children
- Grouped children should not be selectable
- Disable selecting a shape inside a group
- After grouping, draw selection box around group. Child shapes should not be selected.
- Implement ungroup
- Show grouped children in debug view
- Serialize / deserialize groupbox with children
- Disable group/ungroup menu if selected shape has no children
- Changing z-order of groupbox.
- When creating a groupbox, it should be inserted in the z-order right after the bottomost child being grouped.
- Show grouped box in gray.
- Bug (fixed): Connector from outside of group to a grouped child does not preserve connection to child when group moves.
- Bug (fixed): Create 2 boxes, connect them, group one of the boxes, then move the group - a small trail of the connector remains
- Bug (fixed): Delete groupbox doesn't quite work.
- Bug (fixed): Copy and paste of a groupbox faults.
- Bug (fixed): group 2 shapes, add a third, click on "Group" main menu, then click on grouped shape. Null reference exception is thrown in mouse controller.
- Bug (fixed): After grouping, move canvas, and grouped elements move 2x because they're in two lists now
The GroupBox Shape
At its simplest level, grouping shapes "merely" required creating a GroupBox
element, which is not selectable from the toolbar:
(GroupBox.cs)
public class GroupBox : Box
{
public GroupBox(Canvas canvas) : base(canvas)
{
FillBrush.Color = Color.FromArgb(240, 240, 240);
}
public override List<ShapeAnchor> GetAnchors()
{
return new List<ShapeAnchor>();
}
public override void Move(Point delta)
{
base.Move(delta);
GroupChildren.ForEach(g =>
{
g.Move(delta);
g.UpdatePath();
g.Connections.ForEach(c => c.ToElement.MoveElementOrAnchor
(c.ToConnectionPoint.Type, delta));
});
}
}
Notice a few things:
- The default fill color is changed to light grey which I like because it visually indicates a collection of grouped shapes.
- The
GroupBox
cannot be resized -- sorry, no dynamic resizing of the shapes in the GroupBox
(yet!) - The only thing the
GroupBox
needs to be responsible for is moving the shapes contained within it.
It should be noted that any shape can technically behave as a container for other shapes because GroupChildren
is actually a collection implemented for each shape:
(GraphicElement.cs)
public class GraphicElement : IDisposable
{
...
public List<GraphicElement> GroupChildren = new List<GraphicElement>();
public GraphicElement Parent { get; set; }
...
While this feature isn't implemented (you can't have a diamond-shaped group), it does leave the implementation open to non-rectangular groups.
Time for a Major Design Decision
Grouping left me with a design decision. Before grouping was implemented, all elements were implemented in a single collection, "elements", from which the z-order of shapes was implicitly determined by the order of the shapes in the list:
(BaseController.cs)
public abstract class BaseController
{
...
public ReadOnlyCollection<GraphicElement> Elements { get { return elements.AsReadOnly(); } }
...
So the question was:
- Do I maintain a flat list of all shapes, whether they are grouped or not, or...
- Does the
GroupBox
maintain a list of its own grouped shapes?
There are pros and cons to each implementation:
- If a flat list is maintained:
- It allows ungrouped shapes to slide between grouped shapes in the z-order, which is a feature I've sometimes wanted in Visio
- I don't have to touch the serializer/deserializer in any major way except to serialize the IDs of grouped shapes and fix up those IDs on deserialization.
- I don't have to touch the connector "snap" behavior, as I don't need to drill into grouped shapes to see if a connector should snap to an inner shape.
- If a hierarchical collection of shapes is implemented:
- Moving the groupbox in the z-order is trivial, I really don't have to touch the implementation there.
- Serialization needs a significant refactoring because the collection of shapes is now hierarchical.
- Connector "snap" behavior needs to drill into groups.
- The ability to move a shape in-between the z-order of the grouped shapes is not possible.
I decided to stick with the flat list of shapes, which resulted in some interesting implementation of how a grouped set of shapes (remember, this can include nested shapes) is changed in the z-order.
In the screenshot at the start of this section, while it looks hierarchical, that is purely something the debug tree setup does -- it only looks that way, you'll still see the child shapes as top-level items in the list.
Creating a GroupBox
At the UI level, grouping shapes is straight forward:
(MenuController.cs)
private void mnuGroup_Click(object sender, EventArgs e)
{
if (canvasController.SelectedElements.Any())
{
FlowSharpLib.GroupBox groupBox = canvasController.GroupShapes
(canvasController.SelectedElements);
canvasController.DeselectCurrentSelectedElements();
canvasController.SelectElement(groupBox);
}
}
Notice that the shapes to be grouped are deselected after they are grouped, and the GroupBox
is instead selected.
Internally, something important takes place:
public GroupBox GroupShapes(List<GraphicElement> shapesToGroup)
{
GroupBox groupBox = null;
groupBox = new GroupBox(canvas);
groupBox.GroupChildren.AddRange(shapesToGroup);
Rectangle r = GetExtents(shapesToGroup);e4
r.Inflate(5, 5);
groupBox.DisplayRectangle = r;
shapesToGroup.ForEach(s => s.Parent = groupBox);
IEnumerable<GraphicElement> intersections = FindAllIntersections(groupBox);
EraseTopToBottom(intersections);
int insertionPoint = shapesToGroup.Select
(s => elements.IndexOf(s)).OrderBy(n => n).Last() + 1;
elements.Insert(insertionPoint, groupBox);
intersections = FindAllIntersections(groupBox);
DrawBottomToTop(intersections);
UpdateScreen(intersections);
return groupBox;
}
protected Rectangle GetExtents(List<GraphicElement> elements)
{
Rectangle r = elements[0].DisplayRectangle;
elements.Skip(1).ForEach(el => r = r.Union(el.DisplayRectangle));
return r;
}
Besides the GroupBox
being created slightly larger than the union of all the shapes, notice that the GroupBox
is inserted immediately after the lowest z-order shape to be grouped.
User Experience (UX)
The user experience with grouped shapes requires some consideration:
- Shapes within the group cannot be selected.
- Hovering over a grouped shape does not show anchors -- you cannot resize a shape once it's been grouped.
- The user cannot move a grouped shape once it has been grouped.
- The groupbox itself cannot be resized (no anchors).
- The user should still be able to attach connectors to grouped shapes (as shown in the screenshot above).
This required some minor modifications of the mouse controller, for example, preventing anchors from showing over grouped shapes:
(MouseController.cs)
router.Add(new MouseRouter()
{
RouteName = RouteName.HoverOverShape,
MouseEvent = MouseEvent.MouseMove,
Condition = () => !DraggingSurface && !DraggingShapes &&
!SelectingShapes && HoverShape == null &&
CurrentButtons == MouseButtons.None &&
Controller.IsShapeSelectable(CurrentMousePosition) &&
Controller.GetShapeAt
(CurrentMousePosition).Parent == null,
Action = () => ShowAnchors(),
});
And preventing grouped shapes from being selected:
(MouseController.cs)
protected void SelectShapesInSelectionBox()
{
Controller.DeleteElement(SelectionBox);
List<GraphicElement> selectedElements = new List<GraphicElement>();
Controller.Elements.Where(e => !selectedElements.Contains(e) && e.Parent == null &&
e.UpdateRectangle.IntersectsWith(SelectionBox.DisplayRectangle)).ForEach((e) =>
{
selectedElements.Add(e);
});
Controller.DeselectCurrentSelectedElements();
Controller.SelectElements(selectedElements);
Controller.Canvas.Invalidate();
}
Z-Ordering
Z-ordering, particularly moving a grouped shape up/down the z-order, took a lot of thinking as to how to do it. The complexity is a direct result of the fact that I chose not to implement a hierarchical list of elements, as discussed at the beginning.
Observe the z-order of these ungrouped shapes:
Now notice how the shapes appear when I group the red and blue boxes (I've moved things a bit too, because otherwise the change would not be obvious):
Notice that the z-order is maintained! The green box is still between the red and blue boxes. This is an implementation choice, and if you've worked with Visio, you'll notice Visio handles it differently -- here's what Visio looks like after grouping the red and blue boxes:
Personally, I prefer my implementation. Furthermore, in the Visio implementation, you cannot slide a shape in between the shapes in the group. In my implementation, you can (here, the yellow shape is moved under the blue box and above the yellow box):
Topmost and bottommost z-ordering is fairly straight forward:
(BaseController.cs)
public void Topmost()
{
EraseTopToBottom(elements);
selectedElements.OrderByDescending(el => elements.IndexOf(el)).ForEach(el =>
{
elements.Remove(el);
elements.Insert(0, el);
el.GroupChildren.OrderByDescending(child=>elements.IndexOf(child)).ForEach
(child => MoveToTop(child));
});
DrawBottomToTop(elements);
UpdateScreen(elements);
}
public void Bottommost()
{
EraseTopToBottom(elements);
selectedElements.OrderBy(el => elements.IndexOf(el)).ForEach(el =>
{
elements.Remove(el);
el.GroupChildren.OrderBy(child=>elements.IndexOf(child)).ForEach
(child => MoveToBottom(child));
elements.Add(el);
});
DrawBottomToTop(elements);
UpdateScreen(elements);
}
The "trick" here is how the selected elements are ordered. For a topmost move, the selected shape order is reversed because, on an insert, we're of course inserting before the specified index (0 for topmost.)
Moving a group up or down is much more complicated! This code handles both ungrouped and grouped shapes:
(BaseController.cs)
protected void MoveUp(IEnumerable<GraphicElement> els)
{
els.OrderBy(el=>elements.IndexOf(el)).ForEach(el=>
{
List<GraphicElement> childElements = new List<GraphicElement>();
RecursiveGetAllGroupedShapes(el.GroupChildren, childElements);
childElements = childElements.OrderBy(e => elements.IndexOf(e)).ToList();
childElements.ForEach(child => elements.Remove(child));
int idx = elements.IndexOf(el);
int targetIdx = idx > 0 ? idx - 1 : idx;
if (targetIdx != idx)
{
elements.Swap(idx, idx - 1);
}
childElements.AsEnumerable().Reverse().ForEach
(child => elements.Insert(targetIdx, child));
});
}
protected void MoveDown(IEnumerable<GraphicElement> els)
{
els.OrderByDescending(e => elements.IndexOf(e)).ForEach(el =>
{
List<GraphicElement> childElements = new List<GraphicElement>();
RecursiveGetAllGroupedShapes(el.GroupChildren, childElements);
childElements = childElements.OrderBy(e => elements.IndexOf(e)).ToList();
childElements.ForEach(child => elements.Remove(child));
int idx = elements.IndexOf(el);
int targetIdx = idx < elements.Count - 1 ? idx + 1 : idx;
if (targetIdx != idx)
{
elements.Swap(idx, idx + 1);
}
childElements.AsEnumerable().Reverse().ForEach
(child => elements.Insert(targetIdx, child));
});
}
protected void RecursiveGetAllGroupedShapes
(List<GraphicElement> children, List<GraphicElement> acc)
{
acc.AddRange(children);
children.ForEach(child => RecursiveGetAllGroupedShapes(child.GroupChildren, acc));
}
The reason for this complexity should be apparent when we look at the z-order of the shapes in the debug window, which we read from topmost to bottommost:
At this point in time, the "Ungrouped Rectangle" is actually below the "Inner Group" but above the "Outer Group." And because the outer group contains the inner group, the outer group is last in the z-order (at least at this point.) When we move the outer group (only the outer group can be selected at this point!), we also have to ensure that any child groups are preserved. So here's what it looks like after we move the group up:
The entire collection of shapes, including the sub-groups has been moved above the only other shape in this diagram, the "Ungrouped Rectangle."
Fun stuff! I'll leave it to the reader to explore the relatively minor changes that were made for copy and paste and delete to work.
10/18/2016
After the 10/15 release, I discovered several bugs:
- Connection points not hiding after mouse is release after connecting a connector and moving away from connector's anchor.
- Dragging from toolbox and connecting to another shape
- Dragging from canvas and connecting to another shape
- Paste doesn't continue working after changing text of box, clicking on canvas, then Ctrl+V
- Copy & paste crashes - occurred when selecting a shape with a connector, but not selecting the connector.
- Copy & paste crashes - selecting a shape and a connector, which is attached to another shape, crashes.
- Drop three shapes, delete the last one, select one of the remaining shapes. Crashes.
- Save crashed due to delete bug issue.
At the moment, these have been corrected in the GitHub repo, I'll update the code download here soon.
10/15/2016
I started noticing some strange behaviors, so I implemented a tree view of the shapes and their connections. This will come in handy as well later when adding grouped shapes and so forth. Here's some examples of a few bugs.
Connectors not Setting Connected Shape on Load
In this test (actually initially intended to figure out why a multi-select drag operation was leaving trails):
Notice the debug tree:
Specifically, that the dynamic connector is missing the shapes to which it is connected!
If I draw these shapes from a clean slate, rather than loading them from a file, notice the dynamic connector has the right references:
Oddly, this bug does not usually manifest as problem. The problem was created when I changed the deserializer (see Copy & Paste below) to assign new GUIDs. The old GUIDs were not being mapped to the new GUIDs. The fix is:
public override void FinalFixup(List<GraphicElement> elements,
ElementPropertyBag epb, Dictionary<Guid, Guid> oldNewGuidMap)
{
base.FinalFixup(elements, epb, oldNewGuidMap);
StartConnectedShape = elements.SingleOrDefault
(e => e.Id == oldNewGuidMap[epb.StartConnectedShapeId]);
EndConnectedShape = elements.SingleOrDefault
(e => e.Id == oldNewGuidMap[epb.EndConnectedShapeId]);
}
Now, on a load, I get the correct shapes assigned to the connector. This also fixed the bug where connectors were not being detached from shapes, because now the two-way reference is working!
Trails Left with Multiple Shape Dragging with Connectors
Notice the trail left on the triangle "3". This occurs when the selected shapes "1" and "2" are dragged to the right over shape "3". Why?
We can see the problem here:
Notice that the horizontal and vertical lines comprising the connector are being erased first -- the z-order is not being preserved! The culprit in the code is this:
(BaseController.cs)
protected void EraseTopToBottom(IEnumerable<GraphicElement> els)
{
Trace.WriteLine("EraseTopToBottom");
els.Where(e => e.OnScreen()).ForEach(e => e.Erase());
}
The elements being erased are expected to be in the correct z-order. Should this code assume correct ordering? What about this code:
(BaseController.cs)
public void DrawBottomToTop(IEnumerable<GraphicElement> els, int dx = 0, int dy = 0)
{
Trace.WriteLine("DrawBottomToTop");
els.Reverse().Where(e => e.OnScreen(dx, dy)).ForEach(e =>
{
e.GetBackground();
e.Draw();
});
}
Here again, the code assumes the list is ordered! How to fix this defensively but without resulting in excessive sorting?
We can look at the real culprit, which is the call here:
List<GraphicElement> intersections = new List<GraphicElement>();
selectedElements.ForEach(el => FindAllIntersections(intersections, el, dx, dy));
...
EraseTopToBottom(intersections);
Notice how ordering is performed here:
protected IEnumerable<GraphicElement>
EraseTopToBottom(GraphicElement el, int dx = 0, int dy = 0)
{
List<GraphicElement> intersections = new List<GraphicElement>();
FindAllIntersections(intersections, el, dx, dy);
IEnumerable<GraphicElement> els = intersections.OrderBy(e => elements.IndexOf(e));
els.Where(e => e.OnScreen(dx, dy)).ForEach(e => e.Erase());
return els;
}
This is the one and only time that ordering of elements is ever performed, and from a defensive programming perspective, it would make sense to always return an ordered list of intersecting shapes, given the use case. This requires refactoring the FindAllIntersections
into two functions, since the process is recursive:
public IEnumerable<GraphicElement> FindAllIntersections
(GraphicElement el, int dx = 0, int dy = 0)
{
List<GraphicElement> intersections = new List<FlowSharpLib.GraphicElement>();
FindAllIntersections(intersections, el, dx, dy);
return intersections.OrderBy(e => elements.IndexOf(e));
}
private void FindAllIntersections(List<GraphicElement> intersections,
GraphicElement el, int dx = 0, int dy = 0)
{
elements.Where(e => !intersections.Contains(e) &&
e.UpdateRectangle.IntersectsWith(el.UpdateRectangle.Grow(dx, dy))).ForEach((e) =>
{
intersections.Add(e);
FindAllIntersections(intersections, e);
});
}
Notice the recursive function is marked as private
, so we don't accidentally use it outside of the controller class or in a derived controller class. A good example of why/when to use the private
scope. As a result, this broke some code (which is good!) that needed to now call the public FindAllIntersections
method, revealing other areas (the paste code) where this bug was potentially affecting the rendering.
Unfortunately, while this definitely found an issue, it did not fix the problem. Notice the z-order of the shapes:
The "left" triangle is underneath the "up" triangle. So why is the "left" triangle being seen as above the "up" triangle? This doesn't happen why I move either of the two triangles individually:
Here, we have the correct order: up-left. Above (earlier) we have the wrong order (left-up). Why?
The problem is that the controller is still using the wrong method, from which the private scope doesn't protect us:
List<GraphicElement> intersections = new List<GraphicElement>();
selectedElements.ForEach(el => FindAllIntersections(intersections, el, dx, dy));
It's better to rename the recursive method:
private void RecursiveFindAllIntersections(List<GraphicElement> intersections,
GraphicElement el, int dx = 0, int dy = 0)
Now the compiler identifies the use cases (only one) where the wrong method is being called, and we can fix the problem:
List<GraphicElement> intersections = new List<GraphicElement>();
selectedElements.ForEach(el =>
{
intersections.AddRange(FindAllIntersections(el));
});
IEnumerable<GraphicElement> distinctIntersections = intersections.Distinct();
Now the problem is finally fixed!
The erasure of the up-triangle, being in front of the left-triangle, is now correct.
This really illustrates how adding some tracing and diagnostics can help debug problems, and also is some good guidance on how to refactor code (like renaming method being refactored) so that code that uses those refactored methods can be looked at for wrong use case as well.
You can now select multiple shapes and drag them around, using the Shift or Ctrl keys. Hold down Shift or Ctrl when selecting more than one shape, then drag the shapes (holding the left mouse button down.) Once you start dragging, you can release the Shift or Ctrl key.
Code wise, it was interesting to see how much was needed to be touched when changing:
(CanvasController.cs)
public GraphicElement SelectedElement {get {return selectedElement;} }
to:
public List<GraphicElement> SelectedElements { get { return selectedElements; } }
It wasn't too bad, I also pluralized relevant methods, so for example, void DragSelectedElement(Point delta)
is now void DragSelectedElements(Point delta).
This, of course, required modifying a bunch of methods to iterate through the selection list for the drag operations. Once that was done, the "magic" took only a few extra lines of code:
(CanvasController.cs)
if ((Control.ModifierKeys & (Keys.Control | Keys.Shift)) == 0)
{
DeselectCurrentSelectedElements();
}
SelectElement(args.Location);
It's very simple -- if the Shift or Ctrl key is not held down, deselect all the currently selected elements.
One amusing artifact was if I selected the same shape more than once, it would double or triple the movement in the drag operation. Why? Because I kept adding it to the "selected" list. So, selecting an element now tests to see if the element is already selected:
(CanvasController.cs)
public void SelectElement(GraphicElement el)
{
if (!selectedElements.Contains(el))
Selecting Shapes in a Region
You can now select group of shapes with a right-click-drag. This presented a minor technical challenge because I wanted the selection box (which is Box
element) to work regardless of whether the user drew the selection region from the top-left down and right, or "inverted" in the X, Y, or both axis (for example, starting at the bottom-right and moving to the top-left.) Because negative width/heights are not supported, the selection region has to be "normalized":
(CanvasController.cs)
currentSelectionPosition = mousePosition;
int x = currentSelectionPosition.X.Min(startSelectionPosition.X);
int y = currentSelectionPosition.Y.Min(startSelectionPosition.Y);
int w = (currentSelectionPosition.X - startSelectionPosition.X).Abs();
int h = (currentSelectionPosition.Y - startSelectionPosition.Y).Abs();
Rectangle newRect = new Rectangle(x, y, w, h);
UpdateDisplayRectangle(selectionBox, newRect, delta);
When the selection mode is started, a top-level transparent box is created (fun use of an existing shape!):
(CanvasController.cs)
selectionMode = true;
selectionBox = new Box(canvas);
selectionBox.BorderPen.Color = Color.Gray;
selectionBox.FillBrush.Color = Color.Transparent;
selectionBox.DisplayRectangle =
new Rectangle(startSelectionPosition, new Size(SELECTION_MIN, SELECTION_MIN));
Insert(selectionBox);
Enabling the group select feature resulted in the discovery of how interesting the user experience needs to be. In my original implementation, shapes were selected by clicking on them while holding down the Ctrl or Shift key. To drag the selected shapes, you had to hold the Ctrl or Shift key, click on one of the shapes, and start dragging. At that point, you could release the Ctrl or Shift key and continue dragging. This is not how group selection / dragging works in Visio (my bible for how things should work).
The real user experience is as follows:
- Hold down Ctrl or Shift to select multiple objects
- A rectangle of selected shapes appears (we're doing it a bit differently, by showing the selected shapes with their red rectangles)
- If you click down (without releasing) on any of the selected shapes, they all stay selected.
- If you release the mouse button at this point without dragging, the shape becomes deselected.
- Conversely, if, after click-down on a selected shape, you start dragging, all the selected shapes move (again, visually represented slightly differently - Visio continues to show you their original position)
- If you continue holding down the Shift key, Visio implements a "snap to object alignment" feature (something I'd like to do at some point.)
The salient point is #5 - the shape does not deselect unless you haven't initiated dragging.
Implementing this led me to rework the whole way that mouse events (down, up, motion) are handled. The current implementation was becoming a kludge of "if
" statements. Just look at the mouse move event handler (inner code omitted):
Point delta = newMousePosition.Delta(mousePosition);
if (delta == Point.Empty) return;
mousePosition = newMousePosition;
if (dragging)
{
if (selectedAnchor != null)
{
...
if (!connectorAttached)
{
...
}
}
else
{
...
}
}
else if (leftMouseDown)
{
...
}
else if (rightMouseDown)
{
delta = mousePosition.Delta(currentSelectionPosition);
if (!selectionMode)
{
if ((delta.X.Abs() > SELECTION_MIN) || (delta.Y.Abs() > SELECTION_MIN))
{
...
}
}
else
{
...
}
}
else
{
...
if (selectedElements.Count <= 1)
{
...
if (el != showingAnchorsElement)
{
if (showingAnchorsElement != null)
{
...
}
if (el != null)
{
...
}
}
else if (el != null && el == showingAnchorsElement)
{
...
}
}
}
What's needed is a mouse event router! In fact, the whole mouse handling can be moved out of the CanvasController
and into its own MouseController
class. The result is this (a major update):
(MouseController.cs)
public class MouseRouter
{
public MouseController.RouteName RouteName { get; set; }
public MouseController.MouseEvent MouseEvent { get; set; }
public Func<bool> Condition { get; set; }
public Action Action { get; set; }
}
This establishes the conditions for performing a particular action. All mouse events now go through a single router call:
protected virtual void HandleEvent(MouseAction action)
{
CurrentMousePosition = action.MousePosition;
CurrentButtons = Control.MouseButtons;
List<MouseRouter> routes = router.Where
(r => r.MouseEvent == action.MouseEvent && r.Condition()).ToList();
routes.ForEach(r =>
{
Trace.WriteLine("Route: " + r.RouteName.ToString());
r.Action();
});
LastMousePosition = CurrentMousePosition;
}
There are 19 routes!
public enum RouteName
{
StartDragSurface,
EndDragSurface,
EndDragSurfaceWithDeselect,
DragSurface,
StartDragSelectionBox,
EndDragSelectionBox,
DragSelectionBox,
StartShapeDrag,
EndShapeDrag,
DragShapes,
DragAnchor,
HoverOverShape,
ShowAnchors,
ShowAnchorCursor,
ClearAnchorCursor,
HideAnchors,
SelectSingleShape,
AddSelectedShape,
RemoveSelectedShape,
}
Here is an example of a route, namely the shape dragging route:
router.Add(new MouseRouter()
{
RouteName = RouteName.DragShapes,
MouseEvent = MouseEvent.MouseMove,
Condition = () => DraggingShapes &&
HoverShape.GetAnchors().FirstOrDefault(a => a.Near(CurrentMousePosition)) == null,
Action = () =>
{
DragShapes();
DraggingOccurred = true;
},
});
Notice this checks:
- The state, that dragging shapes has been enabled (which is set by the mouse down route on the condition that a shape is selected.)
- We're not dragging an anchor (the mouse isn't near an anchor.)
The salient points of the router is that:
- It manages state flags - a nice separation of concerns between state management and actions for a given state
- The actions are all now very concise, doing one thing and one thing only, and no if statements!
For example:
protected void DragShapes()
{
Point delta = CurrentMousePosition.Delta(LastMousePosition);
Controller.DragSelectedElements(delta);
Controller.Canvas.Cursor = Cursors.SizeAll;
}
Connectors
One issue I had was that connectors. Because they are "moved" when group selected, they were detaching from their connected shape. This kludge (violates my "don't do an is
test") works for now:
(CanvasController.cs)
public void DragSelectedElements(Point delta)
{
selectedElements.ForEach(el =>
{
bool connectorAttached = el.SnapCheck(GripType.Start, ref delta) ||
el.SnapCheck(GripType.End, ref delta);
el.Connections.ForEach(c =>
c.ToElement.MoveElementOrAnchor(c.ToConnectionPoint.Type, delta));
if (el is Connector && selectedElements.Count == 1)
{
MoveElement(el, delta);
}
else if (!(el is Connector))
{
MoveElement(el, delta);
}
UpdateSelectedElement.Fire(this, new ElementEventArgs() { Element = el });
if (!connectorAttached)
{
if (selectedElements.Count == 1)
{
DetachFromAllShapes(el);
}
}
});
}
I'm not particularly proud of that code. Too many if
statements!
Multi-object Dragging Efficiency
The performance right now when selecting more than 10 shapes or so is very poor, resulting in jerky motion. This is most likely because, instead of erasing all the selected shapes, moving them, then redrawing them, the code is currently moving each shape one at a time, resulting in a lot of unnecessary erase/redraws.
At least that's what I thought. While that is part of the issue, the real problem was that the property grid was being updated when each selected element was being dragged!
Hovering over or selecting an anchor point now displays the appropriate cursor arrow for that anchor. This was an easy tweak. First, set the cursor to display when building the anchor list, for example:
(GraphicElement.cs)
anchors.Add(new ShapeAnchor(GripType.TopLeft, r, Cursors.SizeNWSE));
Then set the cursor for the anchor we are near or have selected. This is the code for when the mouse is hovering:
(CanvasController.cs)
protected void SetAnchorCursor(GraphicElement el)
{
ShapeAnchor anchor = el.GetAnchors().FirstOrDefault(a => a.Near(mousePosition));
canvas.Cursor = anchor == null ? Cursors.Arrow : anchor.Cursor;
}
Copy and paste was easy:
(FlowSharpUI.cs)
Copy (was):
string copyBuffer = Persist.Serialize(el);
Clipboard.SetData("FlowSharp", copyBuffer);
Paste (was):
GraphicElement el = Persist.DeserializeElement(canvas, copyBuffer);
Now it's:
Copy (now):
protected void Copy()
{
if (canvasController.SelectedElements.Any())
{
string copyBuffer = Persist.Serialize(canvasController.SelectedElements);
Clipboard.SetData("FlowSharp", copyBuffer);
}
else
{
MessageBox.Show("Please select one or more shape(s).",
"Nothing to copy.", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
I should have tested for a selected shape to begin with.
Deserialization of a selection list is more complicated now. Recall that in the single element deserializer, the GUID was reassigned:
el.Id = Guid.NewGuid();
Easy-peasy. But now, the user can select multiple shapes, possibly connected, and we'd like to preserve the connection information of the pasted shapes, relative to each other. This means we need to create new GUIDs for the pasted shapes and remap the GUIDs of the connectors so that they reference the newly pasted shapes, not their "source" shapes.
This affects the persistence code. It now always creates new GUID's when deserializing. This actually is really useful, especially when we want to import a drawing into an existing drawing, we no longer have to worry about importing the same drawing more than once and having duplicate GUID's:
(Persist.cs)
The internal deserialization is a bit more complicated, as it always creates a map between the old GUID and the possibly new GUID:
private static Tuple<List<GraphicElement>, List<ElementPropertyBag>>
InternalDeserialize(Canvas canvas, string data, Dictionary<Guid, Guid> oldNewIdMap)
{
List<GraphicElement> elements = new List<GraphicElement>();
XmlSerializer xs = new XmlSerializer(typeof(List<ElementPropertyBag>));
TextReader tr = new StringReader(data);
List<ElementPropertyBag> sps = (List<ElementPropertyBag>)xs.Deserialize(tr);
foreach (ElementPropertyBag epb in sps)
{
Type t = Type.GetType(epb.ElementName);
GraphicElement el = (GraphicElement)Activator.CreateInstance(t, new object[] { canvas });
el.Deserialize(epb);
Guid elGuid = el.Id;
elGuid = Guid.NewGuid();
oldNewIdMap[el.Id] = elGuid;
el.Id = elGuid;
elements.Add(el);
epb.Element = el;
}
return new Tuple<List<GraphicElement>, List<ElementPropertyBag>>(elements, sps);
}
The connector deserialization now uses the map:
(Connection.cs)
public void Deserialize(List<GraphicElement> elements,
ConnectionPropertyBag cpb, Dictionary<Guid, Guid> oldNewGuidMap)
{
ToElement = elements.Single(e => e.Id == oldNewGuidMap[cpb.ToElementId]);
ToConnectionPoint = cpb.ToConnectionPoint;
ElementConnectionPoint = cpb.ElementConnectionPoint;
}
And finally, the new deserialization code for paste:
Paste (now):
List<GraphicElement> els = Persist.Deserialize(canvas, copyBuffer);
canvasController.DeselectCurrentSelectedElements();
els.ForEach(el =>
{
el.Move(new Point(20, 20));
el.UpdateProperties();
el.UpdatePath();
canvasController.Insert(el);
canvasController.SelectElement(el);
});
The newly pasted elements are auto-selected.
Import
Since we've made the deserialization process better, we can now import an existing drawing into the current drawing:
private void mnuImport_Click(object sender, EventArgs e)
{
OpenFileDialog ofd = new OpenFileDialog();
ofd.Filter = "FlowSharp (*.fsd)|*.fsd";
DialogResult res = ofd.ShowDialog();
if (res == DialogResult.OK)
{
string importFilename = ofd.FileName;
string data = File.ReadAllText(importFilename);
List<GraphicElement> els = Persist.Deserialize(canvas, data);
elements.AddRange(els);
elements.ForEach(el => el.UpdatePath());
els.ForEach(el => canvas.Controller.SelectElement(el));
canvas.Invalidate();
}
}
Note how the elements are selected so they can subsequently be easily dragged around.
Bug Fixes
Topmost / Bottommost
These operations cannot swap the topmost or bottommost element with the currently selected element, as this changes the z-order of the element currently at the top or the bottom in relation to other elements in the middle. Instead, the element being brought to the top or bottom has to be explicitly placed at the top or bottom:
public void Topmost()
{
selectedElements.ForEach(el =>
{
EraseTopToBottom(elements);
elements.Remove(el);
elements.Insert(0, el);
DrawBottomToTop(elements);
UpdateScreen(elements);
});
}
public void Bottommost()
{
selectedElements.ForEach(el =>
{
EraseTopToBottom(elements);
elements.Remove(el);
elements.Add(el);
DrawBottomToTop(elements);
UpdateScreen(elements);
});
}
Move up/down was unaffected because they are an adjacent swap.
10/10/2016
You can now drag and drop from the toolbox onto the canvas. This was challenging because the when you move the mouse on the toolbox panel, the mouse events come to that panel, even as you cross over to the panel containing the canvas. This required simulating mouse selection and mouse movement on the canvas' panel:
public void OnMouseMove(object sender, MouseEventArgs args)
{
if (mouseDown && selectedElement != null && !dragging)
{
Point delta = args.Location.Delta(mouseDownPosition);
if ((delta.X.Abs() > MIN_DRAG) || (delta.Y.Abs() > MIN_DRAG))
{
dragging = true;
ResetDisplacement();
Point screenPos = new Point(canvas.Width, args.Location.Y);
Point canvasPos = new Point(0, args.Location.Y);
Point p = canvas.PointToScreen(screenPos);
Cursor.Position = p;
GraphicElement el = selectedElement.CloneDefault(canvasController.Canvas);
canvasController.Insert(el);
Point offset = new Point(-el.DisplayRectangle.X - el.DisplayRectangle.Width/2 - 5,
-el.DisplayRectangle.Y + args.Location.Y - el.DisplayRectangle.Height / 2);
if (el is DynamicConnector)
{
offset = offset.Move(8, 6);
}
canvasController.MoveElement(el, offset);
canvasController.StartDraggingMode(el, canvasPos);
canvasController.SelectElement(el);
}
}
else if (mouseDown && selectedElement != null && dragging)
{
Point p = new Point(args.Location.X - canvas.Width, args.Location.Y);
canvasController.DragShape(p);
}
}
Once you start dragging, the mouse cursor is moved over to the canvas and the shape appears:
Toolbox Shape Selection
Notice in the above image that the selected shape in the toolbox is now indicated.
Toolbox Click
If you just click on a toolbox shape, instead of dragging it onto the canvas, the shapes are now positioned next to each other instead of on top of each other:
The above screenshot shows what the canvas looks like after clicking on the square, circle, and triangle. This is a useful feature for when you know you want to work with several shapes - you just click on what you want repeatedly in the toolbox, then move them around on the canvas.
License
The license in the code itself has been changed to CPOL.
10/5/2016 - Nothing exciting, I thought I had a download source link at the top of the article, just noticed it wasn't there. Added it.