65.9K
CodeProject 正在变化。 阅读更多。
Home

FlowSharp

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (202投票s)

2016年10月3日

CPOL

52分钟阅读

viewsIcon

426261

downloadIcon

11000

一个绘图工具

更新原因

  • 将代码组件重构为服务
  • UI现在实现了停靠面板和多文档
  • FlowSharp和FlowSharpCode现已统一
  • 形状的书签导航
  • Ctrl-Tab 导航形状选择历史记录

目录

构建环境

FlowSharp使用Visual Studio 2015构建,并引用.NET 4.6.1。需要VS2015,但代码也可以引用.NET 4.5进行构建。

源代码维护 在这里

引言

我长期以来一直想要一个类似Visio的绘图工具(为了我的一些有趣的想法,但那是另一回事)。虽然通过Visio的COM API工作很棒,但并非每个人都有Visio,并且直接集成到其他应用程序可能有点过度,特别是对于我的目的而言。

开源选项

我想一定有人写过一个小型、可用、有文档的包。我发现了这三个

NShape - 工业应用的.NET绘图框架

相当不错,一套非常全面的形状和功能,但是:连接器行为有点奇怪,旋转似乎不正常,没有虚拟表面,移动大型形状时有一些视觉瑕疵,代码库非常庞大。代码是为.NET 2.0编写的,在为.NET 4.x重新构建时,由于NShape的只读集合与.NET的实现之间存在一些冲突,出现了各种“歧义引用”错误。没有虚拟表面对我来说是一个主要的阻碍。

Crainiate / Open Diagram

其中一个示例看起来很棒,但后台架构未记录且看起来很复杂。我不确定它是否支持虚拟表面,学习它似乎是一项重大任务。

dot-net-diagram

不太完整,没有虚拟表面,我无法让连接器工作。

考虑到代码库的复杂性、.NET版本问题、文档的缺乏以及明显的bug或缺失的功能,深入研究并不值得。因此,FlowSharp应运而生。

FlowSharp

因此,FlowSharp应运而生。此外,我还想玩得开心,拥有并了解代码,并按照我想要的方式实现用户体验。

特点

以下是基本功能列表

虚拟表面

拖动表面即可移动对象。这是一个无限的虚拟表面。

形状的高效渲染

浅灰色矩形是一个有趣的“开关”,您可以在代码中打开它,它会显示正在更新的区域。连接器的区域比必要的要大。

Z轴排序

Z轴排序,带有

  • 移到最上面
  • 移到最下面
  • 上移
  • 下移

是支持的。

形状文本

形状可以包含文本(带字体和颜色属性),还有一个自由文本形状。

轻松添加到任何WinForm应用程序

canvas = new Canvas();
canvas.Initialize(pnlCanvas);

是的,这个控件不是作为真正的用户控件实现的,可以将其拖放到Windows设计器中。

将图表导出为PNG

时髦!

形状尺寸调整的抓手柄

当鼠标悬停在形状上时,会自动显示锚点(抓手柄)。在这种情况下,形状也被选中,这由红色边框指示。

连接器-形状绑定的分离连接点

抓手柄

vs. 连接点

当连接器的锚点接近形状时,会出现连接点(小的蓝色X,在菱形上显示不佳)。

吸附...

海龟??? 不——我是说,自动吸附到附近形状的连接点的吸附锚点。这给了用户一种积极的反馈,表明连接器已连接。要断开连接,请将连接器或连接器的锚点“甩”开连接点。

形状易于添加

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);
    }
  }
}

这并不难,不是吗?

默认控制器处理所有形状/连接器的功能,如移动、尺寸调整、连接

魔法始于此处

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;
}

如果您不相信魔法,请使用基类(或从中派生以创建自己的特殊魔法)

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;
}

换句话说,工具箱本身就是一个画布

使用剪贴板进行复制和粘贴

好了,这真的有多难?

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);
    }
  }
}

所以,显然您可以在FlowSharp实例之间进行复制和粘贴,以及在同一实例内进行复制和粘贴。

未实现的功能

有关最新问题、功能请求和帮助请求,请访问 开放问题

贡献!

欢迎贡献,如果您想贡献,请在此处分叉GitHub仓库 并提交拉取请求

代码方面

目标是编写一些真正可读/可维护的代码。这意味着

  • 小方法(大部分)并关注只做一件事。
  • 扩展方法以清晰地说明操作。
  • 使用?.安全导航和??null合并运算符(?.需要C# 6.0,因此需要VS2015)。
  • 使用了大量的Linq和匿名方法,在我看来,这再次提高了可读性。
  • 良好的OO设计,特别是控制反转,其中形状控制并经常覆盖默认行为。
  • 控制器和模型之间的清晰分离。

扩展方法?

我喜欢扩展方法。非常喜欢。例如

DisplayRectangle.TopRightCorner().Move(-anchorSize, 0)

在我看来,这使得代码非常可读:有了显示矩形,得到右上角并移动它。这是一个很好的从左到右的进展。当然,它也可以这样写

Move(GetTopRightCorner(DisplayRectangle), -anchorSize, 0)

对我来说,可读性差得多。

可读性与性能:优化关键之处

这是计算一些锚点的代码片段

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));

同样,我认为这相当可读,尽管当然,这段代码可以针对DisplayRectangle坐标进行优化。但这里的重点是,这段代码只在鼠标悬停在形状上时调用。它需要完全优化吗?绝对不需要,并且优化版本可能可读性更差。

优化也存在权衡。这段代码片段在鼠标悬停在对象上时被调用,甚至在鼠标在对象内移动时也被调用。我在乎吗?不太在乎。相反,有人可能会想,“每次形状移动时都计算锚点。”那是不明智的,因为某些操作,例如拖动画布移动所有形状,将导致重新计算锚点,尽管我们不需要它们。

绝对坐标系与局部坐标系

如上面的代码所示,所有形状和形状组件都采用绝对坐标。有人可能会认为,形状组件,如锚点和连接点,应该相对于形状——这有一定的逻辑/优雅性——但问题是,对于从形状渲染到UI控制器方面的所有内容,都需要从局部坐标系转换为表面坐标系,例如“用户点击了哪个形状组件?”

因为我使用绝对坐标系,所以绘制锚点等组件非常容易

protected virtual void DrawAnchors(Graphics gr)
{
  GetAnchors().ForEach((a =>
  {
    gr.DrawRectangle(anchorPen, a.Rectangle);
    gr.FillRectangle(anchorBrush, a.Rectangle.Grow(-1));
  }));
}

现在,诚然,使用扩展方法、Linq和匿名方法可能会降低性能——这是一个有趣的权衡:代码可读性与性能。从技术上讲,编译器应该优化代码,但我们都知道实际情况。

开发实践

编写这段代码确实帮助我确定了我认为良好的开发实践

  • 从一组基本需求开始
    • 记录必须具备的附加需求,这些需求是从起始需求演变而来的。
    • 记录“最好有”的功能,并对其进行优先级排序。
    • 记录已知bug。
  • 当一个功能正常工作时,提交代码。
  • 发现bug后,立即修复——如果不能尽快解决,代码更改可能会很显著。
    • 如果需要从某个bug中休息一下,请处理一个与bug完全无关的其他功能,这样您就知道代码不会相互影响。
  • 发现代码异味?立即重构,而不是以后。
  • 尽量记住,所有这些图形对象都需要被处理掉!
  • 将重复的代码移到自己的方法中。
  • 测试这种东西并不容易,因为你基本上必须玩弄UI来测试行为。写下基本的测试场景。
  • 如果您使用if x is [some type],那么您就没有正确地通过良好的对象设计来反转控制。我有很多这样的情况,因为它们很容易编写,但最终,我把它们都重构掉了。在一种情况下,一个复杂的嵌套if-else代码块(长达40行)被简化为一个简单的调用,由子类处理。
  • 编程101
    • 避免魔术数字,将它们放在const变量中。我这里还有一些遗留的违例。
    • 为一切使用有意义的名称。不一定像听起来那么容易。
    • 在代码中,我称之为“元素”,但在文章中,我称之为“形状”。这是一个应该纠正的不一致之处。我可能还应该重构形状的名称,从诸如“Box”之类的内容改为“BoxShape”。命名并非易事!
  • 代码组织:内置形状应该组织在子文件夹中,以保持整洁,但那么基类放在哪里?子文件夹是否只包含具体的形状实现?基类是否应该放在单独的文件夹中?即使对于这样一个简单的项目,代码组织也是一个有趣的问题。

改掉坏习惯

我经常这样做

List<GraphicElement> els = intersections.OrderBy(e => elements.IndexOf(e)).ToList();

当这个更好

IEnumerable<GraphicElement> els = intersections.OrderBy(e => elements.IndexOf(e));

如Stack Overflow上所示

是的,IEnumerable<T>.ToList()确实有性能影响,它是一个O(n)操作,尽管它可能只在性能关键的操作中引起注意。

使用IEnumerable的一个好处是,您可以Reverse列表而不影响主列表!代码仍然可以进一步清理以纠正这个坏习惯。

var与显式类型指定

我喜欢编写我知道变量类型明确的代码

IEnumerable<GraphicElement> els = EraseTopToBottom(el, dx, dy);

在某些情况下,当它并不重要,因为知道类型并不重要时,您会发现一些var的用例,例如这个

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);
}

但总的来说,我喜欢知道我在处理什么,毕竟,C#不是Python或Ruby!

序列化与设计器元数据的分离

我个人也不喜欢用序列化和设计器属性来装饰我的类。它只是给原本干净的模型增加了大量的混乱,特别是当你需要用于画笔、字体、钢笔和颜色等的序列化助手(即自定义属性)时。因此,尽管它增加了代码的复杂性,但有单独的类来管理属性(适合属性网格)和序列化(目前使用XML序列化,但JSON也应该可以正常工作)。

大量挣扎与抗争

(在编写本文时,我改进了对象模型,并删除了不再需要的自定义工具箱处理,消除了80行代码,并添加了一些代码用于其他形状,所以上面的内容只是某个时间点的快照。)

FlowSharp的编写基本上花费了几个完整的周末白天和大多数工作日晚上,所以,大概120小时。这意味着每小时大约11.5行可工作代码!在此过程中,似乎一切都在与我作对。以下是一些更令人难忘的时刻

  • 背景擦除必须补偿
    • 边框画笔宽度
    • 连接点绘制在形状外部的事实
  • 真正的动态连接器本身就是一个小论文,所以我通过不支持它们极大地简化了自己的生活!
  • 线帽
    • GDI提供的线帽无用——它们太小了
    • .NET只提供了一种自定义线帽(箭头)。
    • 自定义线帽很麻烦。目前未实现。搜索发现,似乎没有人实现过其他。
    • 连接器的线帽作为锚点(它是起点/终点)移动时,导致了一些丑陋的代码。
      • 现在回想起来,我可能把它弄得过于复杂了!
  • 连接器和背景重绘
    • 有趣的东西——为了优化情况,一个连接器由单独的线段组成,并且整个形状矩形被覆盖,这样只有线条跟踪它们的背景,而不是整个形状的(可能非常大的!)矩形。
  • 抗锯齿
    • 我以为我的PNG编写器没有抗锯齿,直到我意识到我的查看器正在自动缩放图像以适应显示窗口!
  • 面向对象编程
    • 对象模型重构:形状需要对其命运有很大的控制权。这需要近乎不断的重构,并且有几次糟糕的设计被抛弃,然后开发了新功能。我不确定这是否可以避免,因为许多设计都是关于所需功能的“边学边做”。
    • 令人讨厌的是,在少数基类中有几个方法只是“控制反转”的存根——允许派生类做一些特殊的事情。其中大部分与连接器如何处理某些UI行为有关。
  • 比较画笔颜色
    • 不,您不能执行Pen.Color == Color.Red,因为结构不匹配,并且内置的operator==工作不正确。所以,如果您想让它正常工作,需要pen.Color.ToArgb() == Color.Red.ToArgb()
  • 边界条件
    • 部分或完全在屏幕外的形状
    • 尺寸为0或负数的形状
    • 超过形状边界的字体大小更改(未处理)
    • 使文本形状增长超出其原始背景的字体大小更改(已处理)
    • 边框线宽(可能已处理,不确定)
  • 网格背景
    • 很酷,但如果您拖动整个表面并每次重绘整个窗口,性能会不理想。
    • 权衡(已做出)是仅移动形状。网格更像是看起来很酷的美学事物。
  • 我提到过背景擦除吗?
    • 形状重叠和Z轴排序!
    • 这意味着检测重叠,以便所有重叠的形状可以从上到下擦除,然后从下到上重绘。
  • 序列化
    • 形状维护连接器对象的列表
    • 连接器维护它们连接到谁
    • 您不能直接序列化这些东西,所以每个形状都有一个ID,并且所有东西都必须在反序列化时重新连接。有趣的东西,但实际上并不复杂。
  • 锚点 - 连接点吸附
    • 很酷的功能,效果很好,但如何断开锚点?
    • 这可能是最复杂的一段代码。
  • 鼠标事件
    • 叹气。为什么鼠标单击还会触发鼠标移动事件,而位置没有变化??
  • 以及我到现在为止已经忘记的十几个东西...

动态连接器

由于连接器非常复杂(至少我现在是这么认为的),我

  1. 要求用户通过选择正确的通用连接器形状来协助。
  2. 不要绕过形状。唉。
  3. 连接器不能相互连接(如果您启用了此功能,您会注意到拖动已连接的连接器时会出现一些奇怪的行为)。

所以这是另一个开发实践:将算法复杂性推给用户体验,让用户自己解决!相反,用户可以为任务选择正确的连接器

三个基本连接器可以进行方向调整以制作所需的连接器。希特勒会喜欢这些。

对象模型

是的,这是使用FlowSharp创建的!

几个bug的故事

反序列化ID bug

当然,在尝试再次加载此图表后,我发现了一个愚蠢的bug(大多数bug不都是愚蠢的吗?)我使用了复制粘贴来处理矩形,而这段代码

el.Id = Guid.NewGuid(); // We get a new GUID when deserializing a specific element.
el.Deserialize(epb); // A specific deserialization does not preserve connections.

在复制的形状反序列化之前,就已经分配了一个新的形状ID。

这破坏了这段代码

ToElement = elements.Single(e => e.Id == cpb.ToElementId);

因为有多个形状具有相同的ID!我不确定,即使我写了单元测试,我也会检查唯一ID,这充分说明了(我们都知道,对吧?)通过单元测试并不能保证应用程序没有bug。

画布移动bug

这是另一个无法进行单元测试的。我最初的“移动所有内容”的代码,发生在您拖动画布本身时,看起来是这样的

elements.ForEach(el =>
{
  MoveElement(el, delta);
});

还不错,但请注意MoveElement做了什么

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);
  }
}

这对于移动单个元素非常有用——它查找所有相交的形状,擦除它们,移动所需的形状,然后重新绘制它们。元素在屏幕外的情况也得到了很好的处理。

这段代码的问题是,如果您移动所有内容,会导致移动过程非常卡顿。这段代码

// "Smart" move, erases everything first, moves all elements, then redraws them.
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);
}

解决了这个问题——当拖动画布时,用户现在会体验到非常流畅的操作!同样,这是单元测试无法发现的东西。

FlowSharpLib 代码

代码分为两个项目

  • FlowSharp - UI本身,包含工具箱、画布和属性网格
  • FlowSharpLib - 在画布上绘制形状的所有组件,包括画布控制器

基础知识

初始化

(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);
}

这里初始化了画布和画布控制器。初始元素集为空(有一些注释掉的代码用于从一些元素开始,这有助于测试)。UI将ElementSelected连接到更新菜单,并且还初始化了工具箱控制器。

自定义工具箱形状

(ToolboxText.cs)

在某个实例中,Text形状,我完全覆盖了默认形状的渲染

using System.Drawing;

namespace FlowSharpLib
{
  /// <summary>
  /// Special rendering for this element in the toolbox only.
  /// </summary>
  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);
    }
  }
}

区别在于,在toolbox中,元素绘制了一个大字母“A”,但实际元素默认为较小的字体和文本“[输入文本]”。

完全重绘

(BaseController.cs, CanvasController.cs, ToolboxController.cs)

画布处理其背景网格并调用控制器必须为画布设置的CanvasPaintComplete Action<>方法。默认情况下,它只做这个

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();
  });
}

为了优化形状的绘制,每当它被移动时,任何相交的元素也必须被擦除。擦除是从上到下进行的,重绘是从下到上进行的。这使得每个形状都能在绘制之前捕获背景。

交集检测如下

/// <summary>
/// Recursive loop to get all intersecting rectangles, 
/// including intersectors of the intersectees, 
/// so that all elements that are affected by an overlap redraw are erased and redrawn, 
/// otherwise we get artifacts of some intersecting elements when intersection count > 2.
/// </summary>
protected void FindAllIntersections(List<GraphicElement> intersections, 
                                    GraphicElement el, int dx = 0, int dy = 0)
{
  // Cool thing here is that if the element has no intersections, 
  // this list still returns that element because it intersects with itself!
  elements.Where(e => !intersections.Contains(e) && 
       e.UpdateRectangle.IntersectsWith(el.UpdateRectangle.Grow(dx, dy))).ForEach((e) =>
  {
    intersections.Add(e);
    FindAllIntersections(intersections, e);
  });
}

一个有趣的递归算法!可选的“增长”因子处理了这样一个事实:在形状移动后,我们需要识别源位置和目标位置的交集。该算法假设移动通常是小增量的。相交的形状(未移动的)具有默认的“增长”因子0。

双缓冲、管理位图和擦除

(Canvas.cs)

实际的Panel基类控件是双缓冲的,这提供了良好的用户体验

public Canvas()
{
  DoubleBuffered = true;

但FlowSharp维护自己的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;
}

另请注意,创建了两个Graphic对象,因为对于某些操作,我们不需要抗锯齿功能。

维护我们自己的位图可以擦除形状

(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();
}

并在形状移动后捕获新的背景

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);
}

更新屏幕本身很简单

(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));
}

这比创建联合矩形快吗?不知道,因为联合矩形可能包含大量不属于形状的空间,例如“L”形图案中的东西。

(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();
}

注意CopyToScreen是如何仅将我们内部位图的区域复制到屏幕上的受影响区域的。奇怪的是,Graphics有一个CopyFromScreen,但没有CopyToScreen,所以我们必须自己编写。

裁剪

(Canvas.cs)

部分在屏幕外的形状被裁剪

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);
  }
}

移动形状涉及

  1. 擦除它及其相交的形状
  2. 更新形状的路径(由连接器形状实现)
  3. 在位图上重绘受影响的形状
  4. 并将位图区域复制到屏幕。

吸附

(CanvasController.cs)

这是CanvasController中最复杂的一段代码。让我们从检测连接器的锚点是否靠近形状的连接点的部分开始

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;
}

此方法返回形状的所有可能的吸附点(连接点),这些连接点靠近锚点。我们使用锚点并不明显,因为传递的参数是连接器的连接点。因为它们与连接器的端点相同,所以我们使用连接器的连接点,而不是费力地去弄清楚代表连接器“尖端”的实际锚点。

实际的吸附检测方法是这个庞然大物

public override bool Snap(GripType type, ref Point delta)
{
  bool snapped = false;

  // Look for connection points on nearby elements.
  // If a connection point is nearby, and the delta is moving toward that connection point, 
  // then snap to that connection point.

  // So, it seems odd that we're using the connection points of the line, 
  // rather than the anchors.
  // However, this is actually simpler, 
  // and a line's connection points should at least include the endpoint anchors.
  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; // calculate to match possible delta sign
      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();

      // Are we attached already or moving toward the shape's connection point?
      if ((neardxsign == 0 || deltaxsign == 0 || neardxsign == deltaxsign) &&
        (neardysign == 0 || deltaysign == 0 || neardysign == deltaysign))
      {
         // If attached, are we moving away from the connection point to detach it?
        if (neardxsign == 0 && neardxsign == 0 && 
          (delta.X.Abs() >= SNAP_DETACH_VELOCITY || delta.Y.Abs() >= SNAP_DETACH_VELOCITY))
        {
          selectedElement.DisconnectShapeFromConnector(type);
          selectedElement.RemoveConnection(type);
        }
        else
        {
          // Not already connected?
          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;
}

算法

  1. 查找附近的形状
  2. 检查形状上的每个连接点,看我们是否已连接或正在朝连接点移动
  3. 如果已连接(neardxsignneardysign== 0),则检查我们是否正在以足够高的“速度”远离,如果是,则分离连接器。
  4. 否则,检查我们是否已连接。如果未连接,则将连接器连接到形状。

附近形状的列表被保留,以便在鼠标抬起时可以擦除连接点

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();
  }
}

在鼠标移动事件处理程序中实现吸附检查

protected void OnMouseMove(object sender, MouseEventArgs args)
{
  Point delta = args.Location.Delta(mousePosition);

  // Weird - on click, the mouse move event appears to fire as well, so we need to check
  // for no movement in order to prevent detaching connectors!
  if (delta == Point.Empty) return;

  mousePosition = args.Location;

  if (dragging)
  {
    if (selectedAnchor != null)
    {
      // Snap the anchor?
      bool connectorAttached = selectedElement.SnapCheck(selectedAnchor, delta);

      if (!connectorAttached)
      {
        selectedElement.DisconnectShapeFromConnector(selectedAnchor.Type);
        selectedElement.RemoveConnection(selectedAnchor.Type);
      }
    }
    else
    {
      DragSelectedElement(delta);
    }
  }

请注意,在Snap方法中,delta是引用。Snap方法在发生吸附时更新此值。在SnapCheck方法中,元素要么基于用户的鼠标移动来移动,要么根据Snap方法确定的实际连接量来移动。

(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;
}

(UpdateSelectedElement事件被触发,以便属性网格可以使用新的位置信息进行更新。)

我们也可以通过移动整个连接器(保持其形状)而不是连接器的锚点来吸附连接器。这将把连接器的任何端点吸附到附近形状的连接点,由负责拖动形状的方法处理。

(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);
  }
}

形状不能相互吸附,所以形状的默认行为是返回false

// Default returns true so we don't detach a shape's connectors when moving a shape.
public virtual bool SnapCheck(GripType gt, ref Point delta) { return false; }

这被DynamicConnector覆盖了

(DynamicConnector.cs)

public override bool SnapCheck(GripType gt, ref Point delta)
{
  return canvas.Controller.Snap(GripType.None, ref delta);
}

水平和垂直线有点奇怪,因为线的移动受限于轴,如果线——您不能创建对角线。所以对于线,如果线被吸附,我们必须移动整条线,否则会创建一条对角线。如果没有吸附动作,那么线将根据其约束进行重置大小。

(Line.cs)

public override bool SnapCheck(ShapeAnchor anchor, Point delta)
{
  bool ret = canvas.Controller.Snap(anchor.Type, ref delta);

  if (ret)
  {
    // Allow the entire line to move if snapped.
    Move(delta);
  }
  else
  {
    // Otherwise, move just the anchor point with axis constraints.
    ret = base.SnapCheck(anchor, delta);
  }

  return ret;
}

创建PNG

(BaseController.cs)

public void SaveAsPng(string filename)
{
  // Get boundaries of all elements.
  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();
}

创建PNG有点“脏”,因为所有形状都需要相对于为PNG创建的位图进行移动,该位图的大小设置为形状的范围。并且每个形状的画布也必须设置,这样形状才能认为它已知附加到PNG的画布。然后,所有这些都必须被撤销。我个人认为这表明“轻微”的设计/实现缺陷,但上面的代码中的解决方法足够简单,可以完成当前的任务。

序列化

(Persist.cs)

至少在调用点,序列化是直接的

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();
}

注意使用了单独的属性包,我用它来保持形状、连接器和内部东西之间的关注点分离。属性包的主要目的是处理.NET无法序列化的图形对象(如字体、颜色和画笔)的序列化。

例如

[XmlIgnore]
public Color BorderPenColor { get; set; }

[XmlElement("BorderPenColor")]
public int XBorderPenColor
{
  get { return BorderPenColor.ToArgb(); }
  set { BorderPenColor = Color.FromArgb(value); }
}

繁重的工作已移交给形状,它们必须处理保存字体、画笔、钢笔和颜色的实际属性。

(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));
}

是的,将需要序列化的属性复制到属性包有点令人讨厌,但我确实喜欢将序列化模型与形状模型分开。

反序列化

(Persist.cs)

反序列化更复杂,因为连接点需要连接到它们的实际对象。

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)
{
  // Fixup Connection
  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));
}

连接器实现它们管理的数据的序列化/反序列化。

(Connection.cs)

/// !!! If this class ends up being subclassed for any reason, 
/// the serializer must be updated to account for subclasses !!!
/// <summary>
/// Used for shapes connecting to lines.
/// </summary>
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;
  }
}

最后,当连接对象都反序列化后,需要进行最终的修复,将实际形状对象与形状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)

由于没有实际的输入控件,FlowSharp UI项目中的键盘操作必须通过重写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;
}

一个dictionary

protected Dictionary<Keys, Action> keyActions = new Dictionary<Keys, Action>();

使用可以对选定形状执行的键盘操作进行初始化

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));

注意DragSelectedElement也执行吸附检查。使用键盘的“down”来移动连接器

不在吸附范围内 在吸附范围内更近……已吸附!

您无法使用键盘分离连接器(“速度”太小。)这是一个bug吗?

垂直和水平线

这些是非常基本的形状,它们的大小仅限于沿其轴(请参阅锚点约束)。您不能创建对角线。线是根据形状的DisplayRectangle绘制的,例如

(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);
}

锚点约束

锚点的移动受您选择的锚点类型的约束

(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;
}

如上代码所示,角落锚点和动态连接器的StartEnd抓手柄类型不受约束。中间锚点受到约束。

尺寸约束

为了理智起见,形状的最小宽度和高度是有限制的。这意味着您不能通过将形状的左上角移动到其底部或右边缘下方或右侧来反转形状。

这说明了一个正方形(鼠标悬停显示锚点)和一个圆的最小尺寸。这些最小尺寸的原因是为了仍然显示(如左图所示)锚点。这是一段丑陋且有bug的代码,我不会展示。

动态连接器线帽

动态连接器的线帽很复杂。实际上,绘制动态连接器的线条也很复杂。原因如下。首先,让我们看看带有负宽度的线材的默认端帽行为。我们将从一个左右动态连接器开始

并将起点移动,使得宽度为负(开始X > 结束X)

如果我们考虑负宽度,会发生什么

糟糕。端帽绘制在实际线段之外。

让我们看看调整端帽后会发生什么

AdjustableArrowCap adjCap = new AdjustableArrowCap
                            (-BaseController.CAP_WIDTH, BaseController.CAP_HEIGHT, true);

注意负宽度。现在我们得到

注意我们仍然有同样的问题,但现在端帽出现了一个瑕疵——一条白线!

我通过调整端帽属性,确实找到了绘制菱形端帽的方法

adjCap.MiddleInset = -5;

那个发现让我可以将菱形添加到可能的端帽列表中。

回到正题——.NET在“负”方向上绘制端帽的方式似乎有问题(我并不排除我的测试可能存在bug!)这意味着我们必须根据线条的方向重新定向线条和起点/端点。对于直角连接器,水平和垂直线必须重新定向,以便它们始终从右到左和从上到下绘制。

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);
}

当然,端帽也需要重新定向,因为我们刚刚改变了线的方向。

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());
}

相反,这是真正关键的,即使我错了.NET如何处理负方向的端帽,事实是显示矩形可能具有负宽度,这会使Clone方法(和其他方法)失效(它会抛出异常)。

public Bitmap GetImage(Rectangle r)
{
  return bitmap.Clone(r, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
}

所以,即使我对.NET端帽bug错了,.NET在某些操作中仍然不喜欢负方向,事实上,我的库非常倾向于这样一个理念,即矩形的左上角在左下方角的上方和左侧,这意味着宽度和高度预期为> 0。修复它并不难,但这确实需要额外的计算,尤其是在调整“反转”方向的形状大小时。我并不真的想去那里,所以我在动态连接器的工作方式上付出了“代价”。这是权衡情况之一——代码按原样工作。为了简化动态连接器绘图而增加所有其他代码部分(包括处理.NET异常)的复杂性似乎是不值得的。

FlowSharp UI 代码

UI非常简单,包括一个工具箱面板、一个画布和一个属性网格。您会注意到我稍微改变了工具箱布局,并添加了一些来自先前截图的三角形形状。

UI对象模型

工具箱

工具箱说明了创建自己的画布和控制器有多么容易。

ToolboxCanvas

这简直是简单到了极点

(ToolboxCanvas.cs)

public class ToolboxCanvas : Canvas
{
  protected override void DrawBackground(Graphics gr)
  {
  gr.Clear(Color.LightGray);
  }

  protected override void DrawGrid(Graphics gr)
  {
  }
}

在这里,默认画布行为被覆盖了

  • 背景设置为浅灰色。
  • 网格未绘制。

ToolboxController

控制器非常简单——在工具箱中单击一个形状,它就会出现在画布上,并被选中。理想情况下,我希望将工具箱形状拖到画布上,因为这是选择形状后的典型过程:将其拖到某个地方。通过拖动工具箱形状,可以避免额外的鼠标移动和鼠标单击,但我尚未实现这一点。所以控制器现在非常简单。

(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;
  }
}

请注意,形状的克隆及其默认尺寸留给每个形状自己确定。克隆形状非常容易

(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;
}

这只是从形状定义的任何默认值创建一个默认形状。与粘贴相比,粘贴序列化所有形状属性,然后反序列化它们,将新元素插入到当前元素稍微向下和向右的位置,然后进行选择。

(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);

结论

不仅写这个很有趣,而且我现在拥有了一个可用且可扩展的绘图工具。这很重要,因为我想对一些其他概念进行原型设计,其中绘图工具很重要,而且我不想集成Visio,而且我查看过的其他开源绘图工具不符合我的需求。

写这篇文章也很有趣,因为实际上进行了大量的代码清理/重构,因为在写作过程中,我意识到,哇,这可以做得更好。

已知bug

请参阅 GitHub Issues 页。

修订

面向服务的架构

FlowSharp已进行了大量重构,以实现更面向服务的架构。如上图所示,FlowSharp的各个核心组件已分离为不同的服务

  • FlowSharpService - 与停靠管理器接口,以支持画布管理和停靠事件。
  • FlowSharpCanvasService - 处理所有与画布相关的操作。
  • FlowSharpEditService - 处理所有与编辑形状相关的操作:复制、剪切、粘贴、形状文本等。
  • FlowSharpMouseControllerService - 处理与鼠标活动相关的操作。
  • FlowSharpDebugWindowService - 处理调试窗口通知和用户操作。
  • FlowSharpMenuService - 处理与菜单选择相关的操作。
  • FlowSharpPropertyGridService - 处理属性网格。
  • FlowSharpToolboxService - 处理工具箱。

此架构利用了我之前在 The Clifton Method - Part II: Service Manager 一文中写到的SOA方法。重构代码的主要原因是为了使FlowSharp可扩展而无需修改代码库。支持FlowSharpCode的插件模块是FlowSharp如何通过新行为进行扩展的一个绝佳例子。

核心FlowSharp应用程序现在由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>

FlowSharpCode作为服务

请注意,FlowSharpCode仍处于原型阶段!

先前,我在文章 V.A.P.O.R.ware - Visual Assisted Programming / Organizational Representation 中写过将FlowSharp用作代码开发IDE的可能性。在那篇文章撰写时,那里的代码库是一个完全独立的应用程序。现在,支持FlowSharpCode的各种代码片段已实现为服务(请参阅上图)。

  • FlowSharpCodeService - 处理代码与形状的关联。
  • FlowSharpCodeCompilerService - 为形状的代码隐藏和代码生成提供编译器服务。
  • FlowSharpCodeICSharpDevelopService - 代码编辑器使用ICSharpCode使用的Avalon WPF编辑器。

要“激活”FlowSharpCode功能,模块FlowSharpCodeModules.xml被指定为FlowSharp.exe的参数。此文件包含其他服务。

<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'/>

  <!-- Exclude these if you don't want the FlowSharpCode services -->
  <Module AssemblyName='FlowSharpCodeService.dll'/>
  <Module AssemblyName='FlowSharpCodeCompilerService.dll'/>
  <Module AssemblyName='FlowSharpCodeICSharpDevelopService.dll'/>
</Modules>

使用此文件时,应用程序包含一个编辑器面板并添加了一个“构建”菜单

服务基础 - 初始化

每个服务都实现了IModule,它注册一个单例服务。例如,菜单服务实现如下

public class FlowSharpMenuModule : IModule
{
  public void InitializeServices(IServiceManager serviceManager)
  {
    serviceManager.RegisterSingleton<IFlowSharpMenuService, FlowSharpMenuService>();
  }
}

public class FlowSharpMenuService : ServiceBase, IFlowSharpMenuService
{
  ... Implementation if IFlowSharMenuService ...
}

服务基础 - 交互

基类ServiceBase始终使用服务管理器实例进行初始化。

public abstract class ServiceBase : IService
{
  protected ServiceBase();

  public IServiceManager ServiceManager { get; set; }

  public virtual void FinishedInitialization();
  public virtual void Initialize(IServiceManager svcMgr);
}

因此,一旦服务实例化,服务中的任何方法都可以访问其他服务。非常常见的事情是获取活动的画布控制器。

BaseController canvasController = serviceManager.Get<IFlowSharpCanvasService>().ActiveController;

请参考程序集FlowSharpServiceInterfaces,特别是interfaces.cs,查看每个服务公开的方法。

使用DockPanelSuite进行停靠

与文章顶部的截图相比,这是一个略有不同的截图。

停靠使用 DockPanelSuite 实现,并由Clifton.DockingFormService程序集管理(其源代码在 Clifton GitHub仓库 中)。还有一些粗糙的边缘需要解决(与出色的DockPanelSuite无关),但是,诸如持久化布局配置以及保存(和加载!)与图表文件关联的所有文档似乎都能正常工作——尽管我在发现bug时丢失了一些工作!

形状的书签

形状现在可以被书签化。被书签化的形状在形状的显示矩形左上角用一个小绿方块表示。按Ctrl+K或从菜单中选择“转到书签”会弹出一个对话框,您可以从中选择一个被书签化的形状。

所有形状也可以使用Ctrl+H或从菜单中选择“转到形状”进行导航,无论它们是否被书签化。当您选择一个形状时,它会被“聚焦”到画布中心。

Ctrl-Tab 导航选择历史

另一个有用的功能,特别是对于FlowSharpCode,是能够使用Ctrl-Tab导航选择历史记录。

11/22/2016

撤销/重做

实现撤销/重做需要对负责移动形状、锚点和执行吸附活动的负责代码进行重大重构。现在有一个完全独立的控制器,即SnapController,它负责管理吸附行为。如上截图所示

  • 调试窗口允许您检查撤销缓冲区
  • 编辑菜单公开撤销/重做功能

特别感谢CPian Qwertie 分享了管理撤销/重做堆栈的代码。

撤销/重做堆栈的操作原理是,在do(和redo)操作以及undo操作中传递动作。一个直接的例子是选择一个形状

(MouseController.cs)

protected void AddShapeToSelectionList()
{
  // Preserve for undo:
  List<GraphicElement> selectedShapes = Controller.SelectedElements.ToList();

  GraphicElement el = Controller.GetRootShapeAt(CurrentMousePosition);
  Controller.UndoStack.UndoRedo("Select " + el.ToString(),
  () => // "Do/Redo" action
  {
    Controller.DeselectGroupedElements();
    Controller.SelectElement(el);
    justAddedShape.Add(el);
  },
  () => // "Undo" action
  {
    Controller.DeselectCurrentSelectedElements();
    Controller.SelectElements(selectedShapes);
  });
}

通常,您会传递do/redo和undo动作。这些动作必须是100%对称的——undo操作必须使应用程序恢复到操作之前的状态,redo操作必须使应用程序恢复到操作之后的状态。

Qwertie的UndoStack的一个漂亮功能是能够指定do/redo/undo组。

在调试窗口中,do/undo(“do”也意味着“redo”,所以我将不再明确说明)组用字母“F”表示。在上图中,“Attach”和“ShapeMove”是一个组(也包括“Attach”和“AnchorMove”)——形状或锚点的移动导致连接器连接到形状。这对于撤销/重做相关操作,特别是导致连接到/从形状连接/断开的连接器移动,是一个非常有用的功能。

在某些条件下,管理撤销/重做操作会变得相当复杂。考虑移动形状。自然期望的是,从用户单击形状开始,到用户拖动形状释放按钮为止的所有鼠标移动——所有这些鼠标移动都被累积成一个单一的移动动作。说起来容易做起来难,因为这可能还包括连接/断开/重新连接/重新断开操作。

(MouseController.cs)

protected void DragShapes()
{
  Controller.Canvas.Cursor = Cursors.SizeAll;
  Point delta = CurrentMousePosition.Delta(LastMousePosition);

  if (Controller.SelectedElements.Count == 1 && Controller.SelectedElements[0].IsConnector)
  {
    // Check both ends of any connector being moved.
    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);
  }
}

请注意,这里没有调用do/undo!相反,SnapController用于管理所有鼠标移动的运行增量。此外,SnapController管理一个“从一个连接点断开并连接到另一个连接点”操作列表。

// If no current snap action, set it to the action.
// Otherwise, if set, we're possibly undoing the last snap action 
// (these are always opposite attach/detach actions),
// if re-attaching or re-detaching from the connection point. 
// Lastly, if attaching to a different connection point, buffer the last snap action 
// (which would always be a detach)
// and set the current snap action to what will 
// always be the attach to another connection point.
protected void SetCurrentAction(SnapAction action)
{
  if (currentSnapAction == null)
  {
    currentSnapAction = action;
  }
  else
  {
    // Connecting to a different shape?
    if (action.TargetShape != currentSnapAction.TargetShape
    // connecting to a different endpoint on the connector?
    || action.GripType != currentSnapAction.GripType
    // connecting to a different connection point on the shape?
    || action.ShapeConnectionPoint != currentSnapAction.ShapeConnectionPoint)
    {
      snapActions.Add(currentSnapAction);
      currentSnapAction = action;
    }
    else
    {
      // User is undoing the last action by re-connecting or disconnecting 
      // from the shape to which we just connected / disconnected.
      currentSnapAction = null;
    }
  }
}

只有当鼠标按钮释放时,所有吸附动作和累积的鼠标移动才会被放入撤销堆栈。但请注意!在这种情况下,没有“Do”操作,因为所有操作都已经执行。相反,有一个单独的“redo”操作,它“通常”与“do”操作相同,但在这种情况下必须单独管理。

(MouseController.cs)

// End shape dragging:
router.Add(new MouseRouter()
{
  // TODO: Similar to EndAnchorDrag and Toolbox.OnMouseUp
  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; // for closure

      Controller.UndoStack.UndoRedo("ShapeMove",
        () => { }, // Our "do" action is actually nothing, 
                   // since all the "doing" has been done.
        () => // Undo
        {
          Controller.DragSelectedElements(delta.ReverseDirection());
        },
        true, // We finish the move.
        () => // Redo
        {
          Controller.DragSelectedElements(delta);
        });
    }

    Controller.SnapController.HideConnectionPoints();
    Controller.SnapController.Reset();
    DraggingShapes = false;
    // DraggingOccurred = false; / Will be cleared by RemoveSelectedShape 
    //              but this is order dependent! TODO: Fix this somehow! :)
    DraggingAnchor = false;
    SelectedAnchor = null;
    Controller.Canvas.Cursor = Cursors.Arrow;
  }
});

SnapController有一个方法用于将do/undo操作排队以进行连接/断开操作。

(SnapController.cs)

protected void DoUndoSnapAction(UndoStack undoStack, SnapAction action)
{
  SnapAction closureAction = action.Clone();

  // Do/undo/redo as part of the move group.
  if (closureAction.SnapType == SnapAction.Action.Attach)
  {
    undoStack.UndoRedo("Attach",
    () => closureAction.Attach(),
    () => closureAction.Detach(),
    false);
  }
  else
  {
    undoStack.UndoRedo("Detach",
    () => closureAction.Detach(),
    () => closureAction.Attach(),
    false);
  }
}

请注意,false表示这些操作属于一个组——它们总是与用户移动连接器或连接器锚点相关联,因此它们总是与该活动分组。同时请注意闭包是如何用于保留操作状态的。

辅助类SnapAction维护附加或分离连接器到/从形状所需的一切。

(SnapController.cs)

public class SnapAction
{
  public enum Action
  {
    Attached,
    Attach,
    Detach,
  }

  public Point Delta { get; protected set; }
  public Action SnapType { get; protected set; }

  // Exposed primarily for debugging purposes:
  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;

  /// <summary>
  /// Used for specifying ignore mouse move, for when connector is attached 
  /// but velocity is not sufficient to detach.
  /// </summary>
  /// <param name="action"></param>
  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;
  }
}

学到的教训

必须重构形状/锚点移动和吸附检查代码的原因是,最初,吸附检查埋藏在连接器形状和连接器锚点移动操作中。在实现撤销/重做时,动作必须是离散的——它们需要由顶级用户事件处理程序(在这种情况下是键盘和鼠标事件)管理的离散活动,这就是为什么上面的代码示例显示UndoStack如何在MouseController类中使用。

如果我一开始就考虑了撤销/重做来实现FlowSharp,我可能会省去很多麻烦,以及一些丑陋的代码,在将动作实现为离散活动后,这些代码被消除了。所以,我再怎么强调都不为过

  • 方法应该只做一件事,而且只做一件事
  • 不要纠缠正交活动

过一段时间,您可能会发现拥有并不像想要的那么令人愉悦——《星际迷航》,《阿莫克时间》

最重要的是

  • 不要混淆“我能做什么”和“如果我能做,就做”——确定您是否能做某事,以及做这件事,是两件不同的事情。

除了产生难以调试的隐藏副作用外,它还使得实现某些行为(如撤销/重做)成为一场噩梦,这将迫使您(如果您想正确地去做)重写可能大量的代码。幸运的是,在我的例子中,重写是最小的,我能够重用大部分关键的“活动”代码,并抛弃大部分“控制逻辑”代码,因为活动的分离现在更加清晰。

成功解耦活动的关键之一是,不要立即执行操作,而是返回一个数据包,其中包含执行(和撤销)操作所需的一切。比较一下新代码的截断片段

(SnapController.cs)

if...
{
  // Detach:
  action = new SnapAction(SnapAction.Action.Detach, 
                 selectedElement, 
                 type, 
                 si.NearElement, 
                 si.LineConnectionPoint, 
                 nearConnectionPoint, 
                 delta);
  break;
}
else
{
  // Attach:
  action = new SnapAction(SnapAction.Action.Attach, 
                 selectedElement, 
                 type, 
                 si.NearElement, 
                 si.LineConnectionPoint, 
                 nearConnectionPoint, 
                 new Point(neardx, neardy));
}
...
return action;

与旧代码

if...
{
  // Detach:
  el.DisconnectShapeFromConnector(gripType);
  el.RemoveConnection(gripType);
}
else
{
  // Attach:
  si.NearElement.Connections.Add(
    new Connection() 
      { 
        ToElement = selectedElement, 
        ToConnectionPoint = si.LineConnectionPoint, 
        ElementConnectionPoint = nearConnectionPoint 
      });
  selectedElement.SetConnection(si.LineConnectionPoint.Type, si.NearElement);
}

虽然那看起来足够合理,但旧代码不仅确定是否可以进行吸附,而且还执行了附加/分离。这种混合“我能做什么”和“如果我能,就做”的错误,最终是许多咬牙切齿的根源。请注意,使用新代码,返回的是执行/撤销活动所需的内容,以及吸附动作是否可能的状态信息。这暴露了活动,以便顶层(UI事件处理程序)可以确定动作将在何处/何时实际发生。

11/06/2016

对角线连接器

现在支持对角线连接器。这段代码中最有趣的部分是处理“鼠标是否靠近对角线连接器?”这个问题。

(DiagonalConnector.cs)

public override bool IsSelectable(Point p)
{
  bool ret = false;

  // Determine if point is near line, 
  // rather than whether the point is inside the update rectangle.
  // First qualify by the point being inside the update rectangle itself.
  if (UpdateRectangle.Contains(p))
  {
    // Then check how close the point is.
    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;
}

在这里,一旦鼠标进入对角线连接器定义的矩形,就会进行额外的测试,看鼠标离线条有多近,以确定连接器是否可以选择。

插件支持

由于我想扩展核心集合中的形状(及其行为)的种类,插件能力是绝对必要的。这以ImageShape插件为例进行了演示。

是的,您将卡在我猫咪Earl Grey的照片上,除非您更改资源文件中的位图。

插件目前实现得非常“便宜”。有一个愚蠢的小对话框,您可以在其中列出包含其他形状的程序集。

列出的每个程序集都会被检查,以查找派生自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);
  }
}

一旦找到,插件就会自动添加到工具栏。

(FlowSharpUI.cs)

protected void InitializePluginsInToolbox()
{
  int x = pnlToolbox.Width / 2 - 12;
  List<Type> pluginShapes = pluginManager.GetShapeTypes();

  // Plugin shapes
  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);

    // Next toolbox shape position:
    n += 40;

    if (n > x + 60)
    {
      n = x - 60;
      y += 40;
    }
  }
}

正如您所见,目前的实现非常基本——我甚至没有检查插件的数量是否超过了工具箱的高度!

创建形状插件非常简单

  1. 创建一个DLL项目
  2. 引用FlowSharpLib
  3. 将您的形状派生自GraphicElement
  4. 实现任何用于绘制形状和执行其他操作的覆盖,通常是需要序列化/反序列化的额外信息。

ImageShape

ImageShape使用的插件示例覆盖了绘图和序列化/反序列化。

(ImageShape.cs)

public override void Serialize(ElementPropertyBag epb, 
                               List<GraphicElement> elementsBeingSerialized)
{
  // TODO: Use JSON dictionary instead.
  epb.ExtraData = Filename;
  base.Serialize(epb, elementsBeingSerialized);
}

public override void Deserialize(ElementPropertyBag epb)
{
  // TODO: Use JSON dictionary instead.
  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);
}

目前,不支持保持宽高比,在下一个更新中,我可能会将此形状移到核心库中。

10/23/2016

分组

首先,如上面的截图所示,调试视图现在显示了分组形状的层次结构。您还可以单击调试视图中的一个元素,并且在表面上会指示选定的元素(在本例中是带有蓝色矩形包围的菱形)。

其次,您会注意到组可以嵌套。我主要指出这一点,因为它在Z轴排序中向上和向下移动组方面不像您想的那么容易实现。

实现分组触及了FlowSharpLib代码的几乎所有方面。以下是必须触及的一些问题和代码区域

  • 分组时,取消选中分组的子对象
  • 分组的子对象不应被选中
  • 禁用选择组内的形状
  • 分组后,在组周围绘制选择框。子形状不应被选中。
  • 实现取消分组
  • 在调试视图中显示分组的子对象
  • 序列化/反序列化带子对象的组框
  • 如果选定的形状没有子对象,则禁用组/取消组菜单
  • 更改组框的Z轴排序。
  • 创建组框时,它应该在Z轴排序中插入到分组的子对象的最底部之后。
  • 显示灰色分组框。
  • Bug(已修复):从组外部连接到分组子对象的连接器在组移动时不会保留与子对象的连接。
  • Bug(已修复):创建2个框,连接它们,将其中一个框分组,然后移动组——连接器会留下一个小尾巴。
  • Bug(已修复):删除组框效果不佳。
  • Bug(已修复):复制和粘贴组框会出错。
  • Bug(已修复):对2个形状进行分组,添加第3个,单击“组”主菜单,然后单击分组的形状。在鼠标控制器中抛出NullReferenceException。
  • Bug(已修复):分组后,移动画布,并且分组元素移动2次,因为它们现在在两个列表中。

GroupBox形状

最简单地说,分组形状“仅仅”需要创建一个GroupBox元素,该元素不能从工具栏中选择。

(GroupBox.cs)

public class GroupBox : Box
{
  public GroupBox(Canvas canvas) : base(canvas)
  {
    FillBrush.Color = Color.FromArgb(240, 240, 240);
  }

  public override List<ShapeAnchor> GetAnchors()
  {
    // GroupBox doesn't have anchors - it can't be resized.
    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));
    });
  }
}

请注意几点

  1. 默认填充颜色更改为浅灰色,我喜欢它,因为它在视觉上指示了一组分组的形状。
  2. GroupBox无法调整大小——抱歉,GroupBox中的形状不能动态调整大小(暂时!)。
  3. GroupBox唯一需要负责的是移动其中包含的形状。

值得注意的是,任何形状都可以作为其他形状的容器,因为GroupChildren实际上是为每个形状实现的集合。

(GraphicElement.cs)

public class GraphicElement : IDisposable
{
  ...
  public List<GraphicElement> GroupChildren = new List<GraphicElement>();
  public GraphicElement Parent { get; set; }
  ...

虽然此功能未实现(您不能有一个菱形组),但这确实为非矩形组的实现留下了可能性。

重大设计决策时间

分组让我面临一个设计决策。在分组实现之前,所有元素都在一个集合“elements”中实现,形状的Z轴排序隐式地由列表中形状的顺序决定。

(BaseController.cs)

public abstract class BaseController
{
  ...
  public ReadOnlyCollection<GraphicElement> Elements { get { return elements.AsReadOnly(); } }
  ...

所以问题是

  1. 我是否维护一个包含所有形状的平面列表,无论它们是否分组,或者...
  2. GroupBox是否维护自己的分组形状列表?

每个实现都有优缺点。

  • 如果维护一个平面列表
    • 它允许未分组的形状在Z轴排序中滑到分组形状之间,这是我有时在Visio中想要的功能。
    • 我无需对序列化器/反序列化器进行任何重大更改,只需序列化分组形状的ID并在反序列化时修复这些ID。
    • 我无需修改连接器的“吸附”行为,因为我不需要深入分组形状来查看连接器是否应吸附到内部形状。
  • 如果实现了形状的分层集合
    • 移动组框在Z轴排序中是微不足道的,我真的不用触及那里的实现。
    • 序列化需要重大重构,因为形状的集合现在是分层的。
    • 连接器的“吸附”行为需要深入到组中。
    • 无法在分组形状的Z轴排序之间滑动形状。

我决定坚持使用形状的平面列表,这导致了如何更改分组形状集合(请记住,这可以包括嵌套形状)在Z轴排序中的有趣实现。

在本节开头截图所示,虽然看起来是分层的,但这纯粹是调试树设置所做的——它看起来只是那样,您仍然会在列表中看到子形状作为顶级项。

创建组框

在UI层面,分组形状非常直接

(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);
 }
}

请注意,分组后的形状会被取消选中,而GroupBox会被选中。

内部,发生了重要的事情

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);

  // Insert groupbox just after the lowest shape being grouped.
  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;
}

除了GroupBox比所有形状的并集略大之外,请注意GroupBox插入到要分组的最底层形状之后。

用户体验 (UX)

分组形状的用户体验需要一些考虑。

  1. 组内的形状无法被选中。
  2. 将鼠标悬停在分组形状上不会显示锚点——一旦形状被分组,您就无法调整其大小。
  3. 一旦分组,用户就不能移动分组形状。
  4. 组框本身无法调整大小(没有锚点)。
  5. 用户仍然应该能够将连接器连接到分组形状(如上图所示)。

这需要对鼠标控制器进行一些小的修改,例如,阻止锚点显示在分组形状上。

(MouseController.cs)

// Show anchors when hovering over a shape
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, // no anchors for grouped children.
  Action = () => ShowAnchors(),
});

并阻止选择分组形状。

(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轴排序

Z轴排序,特别是向上/向下移动分组形状,需要很多思考如何做到。复杂性是由于我选择不实现分层元素列表的直接结果,如开头所述。

观察这些未分组形状的Z轴排序

现在注意当我分组红色和蓝色框时形状的出现方式(我也移动了一些东西,否则变化就不会明显)。

请注意,Z轴排序已保持!绿色框仍然在红色和蓝色框之间。这是一个实现选择,如果您使用过Visio,您会注意到Visio的处理方式不同——这是Visio分组红色和蓝色框后的样子。

我个人更喜欢我的实现。此外,在Visio的实现中,您不能将形状滑入组内的形状之间。在我的实现中,您可以(这里,黄色形状被移到蓝色框下方,黄色框上方)。

最顶层和最底层的Z轴排序相当简单。

(BaseController.cs)

public void Topmost()
{
  // TODO: Sub-optimal, as we're erasing all elements.
  EraseTopToBottom(elements);

  // In their original z-order, but reversed because we're inserting at the top...
  selectedElements.OrderByDescending(el => elements.IndexOf(el)).ForEach(el =>
  {
    elements.Remove(el);
    elements.Insert(0, el);
    // Preserve child order.
    el.GroupChildren.OrderByDescending(child=>elements.IndexOf(child)).ForEach
                                      (child => MoveToTop(child));
  });

  DrawBottomToTop(elements);
  UpdateScreen(elements);
}

public void Bottommost()
{
  // TODO: Sub-optimal, as we're erasing all elements.
  EraseTopToBottom(elements);

  // In their original z-oder, since we're appending to the bottom...
  selectedElements.OrderBy(el => elements.IndexOf(el)).ForEach(el =>
  {
    elements.Remove(el);
    // Preserve child order.
    el.GroupChildren.OrderBy(child=>elements.IndexOf(child)).ForEach
                            (child => MoveToBottom(child));
    elements.Add(el);
  });

  DrawBottomToTop(elements);
  UpdateScreen(elements);
}

这里的“技巧”是选定元素的排序方式。对于最顶层的移动,选定形状的顺序是颠倒的,因为在插入时,我们当然是在指定索引(最顶层为0)之前插入。

向上或向下移动组要复杂得多!这段代码同时处理未分组和分组的形状。

(BaseController.cs)

// The reason for the complexity here in MoveUp/MoveDown is 
// because we're not actually "containing" child elements
// of a group box in a sub-list. All child elements are actually part of the master, 
// flat, z-ordered list of shapes (elements.)
// This means we have to go through some interested machinations to 
// properly move nested groupboxes, however the interesting
// side effect to this is that, a non-grouped shape, can slide between shapes in a groupbox!

protected void MoveUp(IEnumerable<GraphicElement> els)
{
  // Since we're swapping up, order by z-order so we're always swapping with the element above,
  // thus preserving z-order of the selected shapes.

  // (from el in els select new { El = el, Idx = elements.IndexOf(el) }).OrderBy
  // (item => item.Idx).ForEach(item =>
  els.OrderBy(el=>elements.IndexOf(el)).ForEach(el=>
  {
    // To handle groupboxes:
    // 1. Recursively get the list of all grouped shapes, which including sub-groups
    List<GraphicElement> childElements = new List<GraphicElement>();
    RecursiveGetAllGroupedShapes(el.GroupChildren, childElements);
    childElements = childElements.OrderBy(e => elements.IndexOf(e)).ToList();

    // 2. Delete all those elements, so we are working with root level shapes only.
    childElements.ForEach(child => elements.Remove(child));

    // 3. Now see if there's something to do.
    int idx = elements.IndexOf(el);
    int targetIdx = idx > 0 ? idx - 1 : idx;

    if (targetIdx != idx)
    {
      elements.Swap(idx, idx - 1);
    }

    // 4. Insert the child elements above the element we just moved up, in reverse order.
    childElements.AsEnumerable().Reverse().ForEach
                                 (child => elements.Insert(targetIdx, child));
  });
}

protected void MoveDown(IEnumerable<GraphicElement> els)
{
  // Since we're swapping down, order by z-oder descending 
  // so we're always swapping with the element below,
  // thus preserving z-order of the selected shapes.
  els.OrderByDescending(e => elements.IndexOf(e)).ForEach(el =>
  {
    // To handle groupboxes:
    // 1. Recursively get the list of all grouped shapes, which including sub-groups
    List<GraphicElement> childElements = new List<GraphicElement>();
    RecursiveGetAllGroupedShapes(el.GroupChildren, childElements);
    childElements = childElements.OrderBy(e => elements.IndexOf(e)).ToList();

    // 2. Delete all those elements, so we are working with root level shapes only.
    childElements.ForEach(child => elements.Remove(child));

    // 3. Now see if there's something to do.
    int idx = elements.IndexOf(el);
    int targetIdx = idx < elements.Count - 1 ? idx + 1 : idx;

    if (targetIdx != idx)
    {
      elements.Swap(idx, idx + 1);
    }

    // 4. Insert the child elements above the element we just moved down, in reverse order.
    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));
}

当我们查看调试窗口中形状的Z轴排序时,应该会显而易见其复杂性,我们从最顶部到最底部读取。

在这一点上,“未分组矩形”实际上在“内部组”下方,但在“外部组”上方。而且由于外部组包含内部组,外部组是Z轴排序中的最后一个(至少在这个点上)。当我们移动外部组(只有外部组此时可以被选中!)时,我们还必须确保任何子组都得以保留。所以这是我们移动组后看起来的样子。

整个形状集合,包括子组,已移到此图表中唯一的其他形状“未分组矩形”之上。

有趣的东西!我将把复制和粘贴以及删除的相对次要更改留给读者自行探索。

 

10/18/2016

在10/15发布后,我发现了一些bug。

  • 在连接器连接并远离连接器锚点后释放鼠标后,连接点未隐藏。
    • 从工具箱拖动并连接到另一个形状
    • 从画布拖动并连接到另一个形状
  • 更改框的文本、单击画布,然后按Ctrl+V后,粘贴仍然有效。
  • 复制和粘贴崩溃 - 当选择带有连接器的形状,但不选择连接器时发生。
  • 复制和粘贴崩溃 - 选择一个形状和一个连接到另一个形状的连接器时崩溃。
  • 拖放三个图形,删除最后一个,选择剩余图形中的一个。崩溃。
  • 由于删除错误,保存时崩溃。

目前,这些问题已经在 GitHub 仓库中得到修正,我将很快在此处更新代码下载。

10/15/2016

调试

我开始注意到一些奇怪的行为,于是我实现了一个图形及其连接的树状视图。这在以后添加分组图形等功能时也会很有用。下面是一些 bug 的例子。

加载时连接器未设置连接的形状

在此测试中(实际上最初是为了弄清楚多选拖动操作为何会留下痕迹)

注意调试树

具体来说,动态连接器丢失了它所连接的形状!

如果我从头开始绘制这些形状,而不是从文件中加载,请注意动态连接器具有正确的引用

奇怪的是,这个 bug 通常不会表现出问题。当我更改反序列化器(见下文的复制和粘贴)以分配新的 GUID 时,问题就产生了。旧的 GUID 未映射到新的 GUID。修复方法是

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]);
}

现在,加载时,我获得了正确的形状分配给连接器。这也修复了连接器未从形状分离的 bug,因为现在双向引用正在工作!

带有连接器的多图形拖动时留下痕迹

注意三角形“3”上留下的痕迹。当选定的形状“1”和“2”向右拖动到形状“3”上方时,就会发生这种情况。为什么?

我们可以在这里看到问题

请注意,构成连接器的水平线和垂直线被先擦除了——Z 顺序未被保留!代码中的罪魁祸首是这个

(BaseController.cs)

protected void EraseTopToBottom(IEnumerable<GraphicElement> els)
{
  Trace.WriteLine("EraseTopToBottom");
  els.Where(e => e.OnScreen()).ForEach(e => e.Erase());
}

被擦除的元素应处于正确的 Z 顺序。这段代码是否应该假定顺序正确?这段代码呢

(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();
  });
}

这里,代码仍然假定列表已排序!如何防御性地修复它,同时又不会导致过度的排序?

我们可以看看真正的罪魁祸首,也就是这里的调用

List<GraphicElement> intersections = new List<GraphicElement>();
selectedElements.ForEach(el => FindAllIntersections(intersections, el, dx, dy));
...
EraseTopToBottom(intersections);

注意这里的排序方式

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;
}

这是唯一执行排序的地方,从防御性编程的角度来看,考虑到用例,返回一个排序的相交形状列表是有意义的。这需要将 `FindAllIntersections` 重构为两个函数,因为这个过程是递归的

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));
}

/// <summary>
/// Recursive loop to get all intersecting rectangles, 
/// including intersectors of the intersectees, so that all elements that
/// are affected by an overlap redraw are erased and redrawn, 
/// otherwise we get artifacts of some intersecting elements when intersection count > 2.
/// </summary>
private void FindAllIntersections(List<GraphicElement> intersections, 
             GraphicElement el, int dx = 0, int dy = 0)
{
  // Cool thing here is that if the element has no intersections, 
  // this list still returns that element because it intersects with itself!
  elements.Where(e => !intersections.Contains(e) && 
  e.UpdateRectangle.IntersectsWith(el.UpdateRectangle.Grow(dx, dy))).ForEach((e) =>
  {
    intersections.Add(e);
    FindAllIntersections(intersections, e);
  });
}

请注意,递归函数被标记为 `private`,因此我们不会意外地在控制器类外部或派生控制器类中使用它。这是一个关于为什么/何时使用 `private` 范围的好例子。因此,这破坏了一些代码(这是好事!),这些代码现在需要调用 `public FindAllIntersections` 方法,暴露出其他区域(粘贴代码)中此 bug 可能影响渲染的情况。

不幸的是,虽然这确实发现了一个问题,但并未解决问题。注意形状的 Z 顺序

“左”三角形在“上”三角形的下方。那么为什么“左”三角形会被视为在“上”三角形的上方?当我单独移动这两个三角形中的任何一个时,都不会发生这种情况

这里,我们有正确的顺序:上-左。上面(前面)我们有错误的顺序(左-上)。为什么?

问题在于控制器仍然在使用错误的方法,而私有范围无法保护我们

List<GraphicElement> intersections = new List<GraphicElement>();
selectedElements.ForEach(el => FindAllIntersections(intersections, el, dx, dy));

最好重命名递归方法

private void RecursiveFindAllIntersections(List<GraphicElement> intersections, 
             GraphicElement el, int dx = 0, int dy = 0)

现在编译器会识别出调用错误方法的用例(只有一个),我们可以修复这个问题

List<GraphicElement> intersections = new List<GraphicElement>();

selectedElements.ForEach(el =>
{
  intersections.AddRange(FindAllIntersections(el));
});

IEnumerable<GraphicElement> distinctIntersections = intersections.Distinct();

现在问题终于解决了!

上三角形(在左三角形前面)的擦除现在是正确的。

这真的说明了添加一些跟踪和诊断功能如何帮助调试问题,并且还为如何重构代码(例如重命名正在重构的方法)提供了一些好的指导,以便可以检查使用这些重构方法的代码是否存在错误的用例。

组合选择

您现在可以选择多个形状并拖动它们,使用 Shift 或 Ctrl 键。选择一个以上形状时按住 Shift 或 Ctrl 键,然后拖动形状(按住鼠标左键)。一旦开始拖动,就可以释放 Shift 或 Ctrl 键。

代码方面,看到更改时需要触摸多少内容很有趣

(CanvasController.cs)

public GraphicElement SelectedElement {get {return selectedElement;} }

to

public List<GraphicElement> SelectedElements { get { return selectedElements; } }

不算太糟,我还将相关方法变成了复数形式,例如,`void DragSelectedElement(Point delta)` 现在是 `void DragSelectedElements(Point delta)`。

当然,这需要修改许多方法来迭代选择列表以进行拖动操作。完成后,“魔术”只用了几行额外的代码

(CanvasController.cs)

if ((Control.ModifierKeys & (Keys.Control | Keys.Shift)) == 0)
{
  DeselectCurrentSelectedElements();
}

SelectElement(args.Location);

这很简单——如果 **Shift** 或 **Ctrl** 键没有被按下,则取消选择所有当前选定的元素。

一个有趣的现象是,如果我多次选择同一个形状,它会在拖动操作中使移动加倍或三倍。为什么?因为我不断地将其添加到“选定”列表中。因此,选择一个元素现在会测试该元素是否已被选中

(CanvasController.cs)

public void SelectElement(GraphicElement el)
{
  // Add to selected elements only once!
  if (!selectedElements.Contains(el))

在区域中选择形状

您现在可以通过右键单击拖动来选择一组形状。这带来了一个小的技术挑战,因为我希望选择框(一个 `Box` 元素)无论用户是从左上角向下右绘制选择区域,还是“反转”X 轴、Y 轴或两者(例如,从右下角开始移动到左上角)都能正常工作。由于不支持负宽度/高度,因此必须“标准化”选择区域

(CanvasController.cs)

currentSelectionPosition = mousePosition;
// Normalize the rectangle to a top-left, bottom-right rectangle.
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);

当选择模式启动时,会创建一个顶层的透明框(对现有形状的有趣用法!)。

(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);

组选择用户体验和鼠标路由器的需求

启用组选择功能使我发现了用户体验的重要性。在我最初的实现中,形状是通过按住 Ctrl 或 Shift 键单击它们来选择的。要拖动选定的形状,您必须按住 **Ctrl** 或 **Shift** 键,单击其中一个形状,然后开始拖动。此时,您可以释放 **Ctrl** 或 **Shift** 键并继续拖动。这与 Visio(我的操作指南)中的组选择/拖动方式不同。

真正的用户体验如下

  1. 按住 **Ctrl** 或 **Shift** 以选择多个对象
  2. 出现一个选定形状的矩形(我们做得略有不同,通过显示带有红色矩形的选定形状)。
  3. 如果您在任何选定形状上单击鼠标(不释放),它们将全部保持选中状态。
  4. 如果此时在不拖动的情况下释放鼠标按钮,则该形状将取消选择。
  5. 反之,如果单击选定形状后开始拖动,则所有选定的形状都会移动(再次,视觉表示略有不同——Visio 继续显示它们的原始位置)。
  6. 如果您继续按住 **Shift** 键,Visio 会实现“对齐对象”功能(这是我想在某个时候实现的功能)。

最重要的一点是 #5——*除非您已开始拖动,否则形状不会取消选择。*

实现这一点使我重新考虑了处理鼠标事件(按下、释放、移动)的整个方式。当前的实现变成了“if”语句的临时解决方案。看看鼠标移动事件处理程序(内部代码已省略)

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 // Mouse Hover!
{
  ...
  if (selectedElements.Count <= 1)
  {
    ...
    if (el != showingAnchorsElement)
    {
      if (showingAnchorsElement != null)
      {
        ...
      }

      if (el != null)
      {
        ...
      }
    }
    else if (el != null && el == showingAnchorsElement)
    {
      ...
    }
  }
}

需要的是一个鼠标事件路由器!事实上,整个鼠标处理可以从 `CanvasController` 移出,放到它自己的 `MouseController` 类中。结果是这样(一项重大更新)

(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; }
}

这建立了执行特定操作的条件。所有鼠标事件现在都通过一个路由器调用

protected virtual void HandleEvent(MouseAction action)
{
  CurrentMousePosition = action.MousePosition;
  CurrentButtons = Control.MouseButtons;

  // Resolve now, otherwise the iterator will find additional routes as actions occur.
  // A good example is when a shape is added to a selection list, using the enumerator, this
  // then qualifies the remove shape route from selected list!
  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;
}

有 19 条路由!

public enum RouteName
{
  StartDragSurface,
  EndDragSurface,
  EndDragSurfaceWithDeselect,
  DragSurface,
  StartDragSelectionBox,
  EndDragSelectionBox,
  DragSelectionBox,
  StartShapeDrag,
  EndShapeDrag,
  DragShapes,
  DragAnchor,
  HoverOverShape,
  ShowAnchors,
  ShowAnchorCursor,
  ClearAnchorCursor,
  HideAnchors,
  SelectSingleShape,
  AddSelectedShape,
  RemoveSelectedShape,
}

这是一个路由示例,即形状拖动路由

// Drag shapes:
router.Add(new MouseRouter()
{
  RouteName = RouteName.DragShapes,
  MouseEvent = MouseEvent.MouseMove,
  Condition = () => DraggingShapes && 
     HoverShape.GetAnchors().FirstOrDefault(a => a.Near(CurrentMousePosition)) == null,
  Action = () =>
  {
    DragShapes();
    DraggingOccurred = true;
  },
});

注意这里检查了

  1. 状态,已启用拖动形状(这由鼠标按下路由在选中形状的条件下设置)。
  2. 我们没有拖动锚点(鼠标不在锚点附近)。

路由器最关键的点是

  1. 它管理状态标志——状态管理和给定状态的操作之间很好的关注点分离。
  2. 所有操作现在都非常简洁,只做一件事,而且没有 if 语句!

例如

protected void DragShapes()
{
  Point delta = CurrentMousePosition.Delta(LastMousePosition);
  Controller.DragSelectedElements(delta);
  Controller.Canvas.Cursor = Cursors.SizeAll;
}

遇到的问题

连接器

我遇到的一个问题是连接器。因为它们在组选时被“移动”了,所以它们与连接的形状分离了。这个临时解决方案(违反了我“不要进行 is 测试”的原则)目前有效

(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));

    // TODO: Kludgy workaround for dealing with multiple shape 
    // dragging with connectors in the selection list.
    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)
    {
      // TODO: Kludgy workaround for dealing with multiple shape 
      // dragging with connectors in the selection list.
      // Detach a connector only if it's the only shape being dragged.
      if (selectedElements.Count == 1)
      {
        DetachFromAllShapes(el);
      }
    }
  });
}

我对此代码并不特别自豪。太多的 if 语句了!

多对象拖动效率

当选择超过 10 个形状时,目前的性能非常差,导致运动卡顿。这很可能是因为代码不是一次性擦除所有选定的形状,移动它们,然后重绘它们,而是当前一次移动一个形状,导致了大量的擦除/重绘。

至少我曾经是这么认为的。虽然这是问题的一部分,但真正的问题是,当拖动每个选定元素时,属性网格正在被更新!

光标

将鼠标悬停在锚点上或选择锚点现在会显示该锚点的适当光标箭头。这是一个简单的调整。首先,在构建锚点列表时设置要显示的光标,例如

(GraphicElement.cs)

anchors.Add(new ShapeAnchor(GripType.TopLeft, r, Cursors.SizeNWSE));

然后为我们靠近或选择的锚点设置光标。这是鼠标悬停时的代码

(CanvasController.cs)

protected void SetAnchorCursor(GraphicElement el)
{
  ShapeAnchor anchor = el.GetAnchors().FirstOrDefault(a => a.Near(mousePosition));
  canvas.Cursor = anchor == null ? Cursors.Arrow : anchor.Cursor;
}

复制和粘贴

复制和粘贴很简单

(FlowSharpUI.cs)

复制(以前)

string copyBuffer = Persist.Serialize(el);
Clipboard.SetData("FlowSharp", copyBuffer);

粘贴(以前)

GraphicElement el = Persist.DeserializeElement(canvas, copyBuffer);

现在是

复制(现在)

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);
  }
}

我一开始就应该测试是否选择了形状。

选择列表的反序列化现在更复杂了。回想一下,在单元素反序列化器中,GUID 被重新分配了

el.Id = Guid.NewGuid(); // We get a new GUID when deserializing a specific element.

简单明了。但现在,用户可以选择多个形状,可能还有连接的形状,我们希望保留粘贴形状的连接信息,相对于彼此。这意味着我们需要为粘贴的形状创建新的 GUID,并将连接器的 GUID 重新映射,以便它们引用新粘贴的形状,而不是它们的“源”形状。

这会影响持久化代码。它现在总是在反序列化时创建新的 GUID。这实际上非常有用,尤其是在我们希望将一个绘图导入到现有绘图中时,我们不再需要担心多次导入同一个绘图并具有重复的 GUID。

(Persist.cs)

内部反序列化有点复杂,因为它总是创建一个旧 GUID 和可能的新 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);
}

连接器反序列化现在使用映射

(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;
}

最后,粘贴的新反序列化代码

粘贴(现在)

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);
});

新粘贴的元素会被自动选中。

导入

由于我们改进了反序列化过程,现在我们可以将现有绘图导入到当前绘图中

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();
  }
}

请注意,元素是如何被选中的,以便随后可以轻松地拖动它们。

Bug 修复

最顶层 / 最底层

这些操作不能将最顶层或最底层的元素与当前选定的元素交换,因为这会改变最顶层或最底层元素相对于中间其他元素 Z 顺序。相反,要带到最顶层或最底层的元素必须被明确地放置在最顶层或最底层。

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);
  });
}

向上/向下移动未受影响,因为它们是相邻交换。

10/10/2016

工具箱拖放

您现在可以从工具箱拖放到画布上。这具有挑战性,因为当您在工具箱面板上移动鼠标时,鼠标事件会发送到该面板,即使您跨越到包含画布的面板。这需要模拟画布面板上的鼠标选择和鼠标移动。

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); // target canvas screen 
                                                                  // position is the toolbox 
                                                                  // canvas width, 
                                                                  // toolbox mouse Y.
      Point canvasPos = new Point(0, args.Location.Y);            // target canvas position 
                                                                  // is left edge, 
                                                                  // toolbox mouse Y.
      Point p = canvas.PointToScreen(screenPos);                  // screen position of 
                                                                  // mouse cursor, relative 
                                                                  // to the target canvas.
      Cursor.Position = p;

      GraphicElement el = selectedElement.CloneDefault(canvasController.Canvas);
      canvasController.Insert(el);
      // Shape is placed so that the center of the shape is at the left edge (X), 
      // centered around the toolbox mouse (Y)
      // The "-5" accounts for additional pixels between the toolbox end and the canvas start,
      // should be calculable by converting toolbox canvas width to screen coordinate 
      // and subtracting
      // that from the target canvas left edge screen coordinate.
      Point offset = new Point(-el.DisplayRectangle.X - el.DisplayRectangle.Width/2 - 5, 
              -el.DisplayRectangle.Y + args.Location.Y - el.DisplayRectangle.Height / 2);

      // TODO: Why this fudge factor for DC's?
      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)
  {
    // Toolbox controller still has control, so simulate dragging on the canvas.
    Point p = new Point(args.Location.X - canvas.Width, args.Location.Y);
    canvasController.DragShape(p);
  }
}

一旦开始拖动,鼠标光标就会移动到画布上,并且形状会出现。

工具箱形状选择

请注意,在上图中,工具箱中的选定形状现在得到了指示。

工具箱单击

如果您只是单击一个工具箱形状,而不是将其拖放到画布上,形状现在会彼此相邻定位,而不是堆叠在一起。

上面的截图显示了单击方形、圆形和三角形后画布的外观。当您知道要使用多个形状时,这是一个有用的功能——您只需在工具箱中反复单击您想要的形状,然后在画布上移动它们。

许可证

代码中的许可证已更改为 CPOL。

2016 年 10 月 5 日 - 没什么令人兴奋的,我以为我在文章顶部有一个下载源链接,才注意到它不在那里。已添加。

© . All rights reserved.