news 2026/10/18 21:15:52

C# WinForms实现类Visio拖拽节点与连线编辑器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# WinForms实现类Visio拖拽节点与连线编辑器

简介:面向C#桌面应用开发者与流程设计工具爱好者,这份资源以Windows Forms Demo为载体,完整演示了类似Visio的图形拖拽交互实现方案。针对OA节点拖拽、自定义图形节点、连接线跟随等常见需求,资源通过INode与ILine接口抽象节点和线条行为,并给出矩形节点、实线连接、鼠标按下/移动/释放事件处理以及双缓冲重绘等关键代码,便于读者快速迁移到流程编辑器或图形化界面项目中。包体共44个文件,以16个cs源码文件为核心,辅以dll运行库、resx/resources资源文件及exe演示程序,总大小仅329KB,结构精简、便于对照阅读。工程从界面搭建、接口定义、绘图逻辑到拖拽测试完整覆盖,运行exe可直接查看效果,阅读源码则能理解节点移动时线条自动更新的实现细节,并掌握通过自定义类扩展更多图形类型与连接线样式的方法。目前已有1704人学习浏览,适合具备一定C#基础、希望掌握拖拽绘图核心思路的开发者参考实践。

1. 为什么用 C# 重写 Visio 式拖拽:从 OA 节点编排到图形化编辑器的第一公里

某公司的 OA 流程配置一直停在表格时代:配置一个审批节点要填上级节点编号,加一个条件分支要复制三份配置再手动改指向,业务人员改一版流程要和开发对半天 Excel。这套 C# 工程解决的问题,就是把「类似 Visio 的拖拽体验」搬进业务流程编辑器:节点拖到画布上,从一个端口拖出连线到另一个端口,保存后生成带坐标和连线关系的流程定义,重新打开还能原样还原。适合两类人:一类在做 OA 节点拖拽、工作流设计器、拓扑图编辑器,想拿一个现成的交互骨架改成自己的业务模型;另一类是刚开始接触 GDI+ 自定义绘图的同学,想找一个能把 MouseDown、Paint、命中测试串起来的完整范例。项目不依赖重型第三方图形库,核心就是一个自定义控件加三个模型类。

2. 整体架构与数据模型:拖拽画布背后的三个核心组件

看源码包之前,先把这套工程的结构理清。它没有把逻辑堆在 Form 里,而是拆成了三层:模型层只管节点和连线的数据,视图层是继承 UserControl 的画布控件,控制层负责鼠标状态和命令管理。画布控件同时扮演视图和控制器的角色,这对中小型图形编辑器来说是最划算的组合,不用引入 MVVM 框架也能保持各模块可替换。

2.1 画布、节点与连线:GDI+ 之上的分层设计

先看两个最基础的模型类。源码里 DiagramNode 和 DiagramConnection 几乎不掺绘制逻辑,做法如下:

public class DiagramNode { public string Id { get; set; } public string NodeType { get; set; } public RectangleF Bounds { get; set; } public PointF Center => new PointF(Bounds.X + Bounds.Width / 2f, Bounds.Y + Bounds.Height / 2f); public List<DiagramPort> Ports { get; set; } = new List<DiagramPort>(); } public class DiagramConnection { public string Id { get; set; } public string StartNodeId { get; set; } public string EndNodeId { get; set; } public int StartPortIndex { get; set; } public int EndPortIndex { get; set; } public string Label { get; set; } }

注意一个设计点:DiagramConnection 本身不保存连线的起点和终点坐标,只保存两个节点 Id 和端口索引。这样节点移动时连线会自动跟随,因为绘制时每次从对应端口重新计算端点。很多类 Visio 拖拽实现翻车就是在这里:把连接线坐标存在 Connection 对象里,节点一拖,线就断在半空。

画布控件的骨架也很简单,构造函数里做两件关键的事:

public sealed class DiagramCanvas : UserControl { private readonly List<DiagramNode> _nodes = new List<DiagramNode>(); private readonly List<DiagramConnection> _connections = new List<DiagramConnection>(); private readonly CommandManager _commandManager = new CommandManager(); public DiagramCanvas() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); BackColor = Color.White; } }

三层关系是这样:Canvas 持有节点和连线集合,负责把集合渲染到屏幕;节点和连线对外暴露数据;CommandManager 记录每次用户操作。Canvas 的 OnPaint 只做一件事,就是遍历集合绘制,不要在 Paint 里执行业务判断,否则后面加撤销和复制会很痛苦。

2.2 数据模型与序列化:让拖拽结果能存能读

OA 节点拖拽做完之后要保存流程,所以序列化是刚需。源码里用的 Newtonsoft.Json 把整个画布内容转成 JSON 文档:

public string Serialize() { return JsonConvert.SerializeObject(new { nodes = _nodes, connections = _connections }); } public void LoadFromJson(string json) { var data = JsonConvert.DeserializeObject<DiagramDocument>(json); _nodes.Clear(); _nodes.AddRange(data.Nodes); _connections.Clear(); _connections.AddRange(data.Connections); _nodes.ForEach(n => n.PropertyChanged += OnNodeChanged); Invalidate(); }

DiagramDocument 是一个轻量容器,只有 Nodes 和 Connections 两个属性。保存时可以再在外面包一层流程 Id、版本号,方便日后做流程版本管理。加载后有一个步骤不能漏:重新订阅节点的 PropertyChanged 事件,否则之后拖拽节点改了 Bounds,画布不会收到通知,界面不会刷新。

实际项目里我一般会把序列化单独放一个 DiagramSerializer 类,Canvas 只暴露 Nodes 和 Connections 的只读列表。这样换数据库、换成二进制存储都不用动画布代码。保存文件格式先用 JSON 起步,等需要保存节点颜色、折叠状态时再往里加字段,这个结构扩展起来很顺手。

2.3 事件分发与命中测试:从鼠标坐标到业务对象的映射

画布上的每一个鼠标动作,第一步都是坐标换算和命中测试。鼠标点击到某个像素点,程序要先回答三个问题:点到了端口吗?点到了节点吗?还是点在空白区域。源码里对应两个方法:

private PointF ToCanvas(Point e) { return new PointF(e.X - AutoScrollPosition.X, e.Y - AutoScrollPosition.Y); } public DiagramNode HitTestNode(PointF pt) { for (int i = _nodes.Count - 1; i >= 0; i--) { if (_nodes[i].Bounds.Contains(pt)) return _nodes[i]; } return null; } public DiagramPort HitTestPort(PointF pt) { foreach (var node in _nodes) foreach (var port in node.Ports) if (port.HitBounds.Contains(pt)) return port; return null; }

ToCanvas 是这套工程里最容易忽略的方法。用户滚动画布以后,鼠标事件的 X、Y 是相对客户区的,而节点的 Bounds 是相对整个逻辑画布的,二者相差一个滚动偏移。如果不减 AutoScrollPosition,拖拽一个节点时,节点会和鼠标相差一截,看起来就像“拖丢了”。

命中顺序也有讲究:先查端口,再查节点,而且节点从后往前遍历。因为后画的节点在上层,倒序遍历能保证最上面的节点被命中,符合直觉。端口热区我习惯做成半径 8 到 10 像素的圆,HitBounds 就用这个圆的外接矩形,太小的话用户很难点中,特别是 OA 节点密集排布的时候。

3. 核心模块拆解:一个拖拽动作从 MouseDown 到 Paint 的完整链路

这一章把交互拆成状态机来看。鼠标按下、移动、抬起,每一步都对应一个状态变化。读源码时如果直接扎进 OnMouseMove,很容易被一堆 if 绕晕,先看状态字段和 MouseDown 的分支,剩下的都顺了。

3.1 自定义控件的选择:UserControl 还是 Panel

很多 Demo 直接继承 Panel,因为 Panel 也是控件,能承载绘制。但源码里选的是 UserControl,区别主要在扩展性上。Panel 的 DoubleBuffered 属性在部分 .NET 版本里是 protected 的,要写子类才能打开;UserControl 默认就是用来做复合控件的,改起来顺手。构造函数里这样配置:

public DiagramCanvas() { DoubleBuffered = true; ResizeRedraw = true; SetStyle(ControlStyles.Selectable, true); TabStop = true; }

DoubleBuffered 解决闪烁,ResizeRedraw 保证窗体尺寸变化时立即重绘。SetStyle(ControlStyles.Selectable, true) 和 TabStop 是给键盘操作准备的。后面做 Delete 删除节点、Ctrl+Z 撤销时,画布必须能获得焦点,否则键盘事件会被其他控件吃掉。

为什么不用 WPF 或者第三方图形库?这套代码的目标场景是内嵌到老牌 OA 系统里,WinForms 和 GDI+ 是很多企业桌面的底线依赖。如果业务方环境允许,你也可以把同样的状态机搬到 WPF 的 Canvas 上,但事件模型和渲染层的替换成本不会低,建议先跑通当前这套再谈迁移。

3.2 拖拽与缩放:Anchor 点的命中与光标切换

源码里的交互状态用一个枚举管理,避免在事件代码里堆布尔变量:

private enum DragMode { None, MoveNode, DrawConnection, RubberBand } private DragMode _mode = DragMode.None; private DiagramNode _draggedNode; private SizeF _dragOffset; private DiagramPort _startPort; private PointF _downPoint; private DiagramConnection _tempConnection;

MouseDown 的核心逻辑是三分支:优先判断端口,再判断节点,最后才是空白区域框选。端口命中时进入 DrawConnection 状态,节点命中时进入 MoveNode 状态。看代码:

protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); Focus(); PointF pt = ToCanvas(e.Location); _downPoint = pt; DiagramPort port = HitTestPort(pt); if (port != null) { _mode = DragMode.DrawConnection; _startPort = port; _tempConnection = new DiagramConnection { Id = Guid.NewGuid().ToString("N"), StartNodeId = port.OwnerId, StartPortIndex = port.Index }; return; } DiagramNode node = HitTestNode(pt); if (node != null) { _mode = DragMode.MoveNode; _draggedNode = node; _dragOffset = new SizeF(pt.X - node.Bounds.X, pt.Y - node.Bounds.Y); Cursor = Cursors.SizeAll; } }

_dragOffset 保存的是鼠标点击位置相对节点左上角的偏移。如果按下时直接把节点左上角设成鼠标坐标,节点会“跳”到鼠标底下,用户会明显感到拖拽手感是坏的。有了偏移,移动时用鼠标坐标减去偏移,节点始终和点击位置保持固定相对距离。

MouseMove 里只处理当前状态:

protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); PointF pt = ToCanvas(e.Location); if (_mode == DragMode.MoveNode && _draggedNode != null) { _draggedNode.Bounds = new RectangleF( pt.X - _dragOffset.Width, pt.Y - _dragOffset.Height, _draggedNode.Bounds.Width, _draggedNode.Bounds.Height); Invalidate(); } else if (_mode == DragMode.DrawConnection) { _tempConnection.EndNodeId = string.Empty; Invalidate(); } }

这里 Invalidate 不是每次移动只画一个像素吗?GDI+ 的 Invalidate 只是把画布标记为需要重绘,真正的绘制在 UI 线程空闲时批量执行。默认的 UserControl 没有先擦除再重画的剧烈闪烁,因为构造函数里已经开了双缓冲。

3.3 连线的绘制与吸附:贝塞尔曲线与最近端口算法

连线不是直线,而是从起点端口延伸到终点端口的贝塞尔曲线。源码里的绘制代码集中在 OnPaint:

protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; foreach (var conn in _connections) { PointF start = GetConnectionPoint(conn, true); PointF end = GetConnectionPoint(conn, false); DrawBezierLine(g, start, end); } foreach (var node in _nodes) DrawNode(g, node); }

DrawBezierLine 里,控制点的偏移方向取决于节点之间的左右关系。起点控制点向右拉,终点控制点向左拉,这样曲线看起来像从节点侧面自然延伸出去。常见错误是控制点偏移量固定为正值,导致从右往左画线时曲线绕一个大圈。

真正的亮点在 MouseUp 时的端口吸附。用户拖到端口附近但没精确瞄准时,程序把临时连线的终点吸到最近端口上:

private const float SnapDistance = 12f; private DiagramPort SnapToPort(PointF current, out PointF snappedPoint) { DiagramPort best = null; var minDist = SnapDistance * SnapDistance; snappedPoint = current; foreach (var node in _nodes) { foreach (var port in node.Ports) { PointF center = GetPortCenter(port); float dx = center.X - current.X; float dy = center.Y - current.Y; float dist = dx * dx + dy * dy; if (dist < minDist) { minDist = dist; best = port; snappedPoint = center; } } } return best; }

MouseUp 时如果捕捉到了目标端口,就把 _tempConnection 补上 EndNodeId 和 EndPortIndex,然后加入 _connections 集合;如果没捕捉到,丢弃临时连线。这个阈值控制很讲究:12 像素吸附半径够大,用户不需要瞄点;但也不要超过 20 像素,否则旁边有几个节点时,连线会吸到错误的端口,反而让用户发火。

3.4 撤销/重做与复制粘贴:命令模式落地

源码没有用一堆全局变量保存“上一步”,而是实现了命令模式。每次用户操作在 MouseUp 时创建一个命令对象,Do 立即执行,Undo 负责还原。命令接口长这样:

public interface ICommand { void Do(); void Undo(); }

以移动节点为例,MouseUp 时把 MouseDown 时的坐标和 MouseUp 时的坐标包进一个命令:

public sealed class MoveNodeCommand : ICommand { private readonly DiagramNode _node; private readonly RectangleF _oldBounds; private readonly RectangleF _newBounds; public MoveNodeCommand(DiagramNode node, RectangleF oldBounds, RectangleF newBounds) { _node = node; _oldBounds = oldBounds; _newBounds = newBounds; } public void Do() => Apply(_newBounds); public void Undo() => Apply(_oldBounds); private void Apply(RectangleF bounds) { _node.Bounds = bounds; } }

注意 MouseMove 过程中每帧都在改 Bounds,但不要每帧都压命令栈,否则按一次 Ctrl+Z 只能回退一像素,撤销等于失效。正确做法是在 MouseUp 时只压一条命令,记录这次拖拽的起点和终点。源码在鼠标抬起时调用 _commandManager.Execute(new MoveNodeCommand(...)),CommandManager 内部维护 UndoStack 和 RedoStack,执行新命令时清空 RedoStack。

复制粘贴是另一块很实用的功能。Ctrl+C 时把选中的节点深拷贝到剪贴板,同时记录端口和连线关系;Ctrl+V 时把每个节点 Id 重新生成,连线 Id 也重新生成,再把整个群组向右下偏移 15 像素。如果不重新生成 Id,粘贴出来的连线和原图连线共用节点引用,删除其中一个,另一个节点上的线也会消失,这就是典型的“看起来保存正常,操作起来全乱”的隐患。

4. 跑通工程:从解决方案结构到 F5 运行

源码包拿到手,先别急着双击 .sln,按下面顺序走一遍,能省掉不少环境问题。这套工程的启动方式不需要特殊配置,有 .NET Framework 4.7.2 或 .NET 6 以上的 WinForms 环境就能跑。

4.1 解决方案结构与关键类说明

工程里核心文件不多,我按职责整理了一张表:

文件职责关键依赖
DiagramCanvas.cs画布控件,负责鼠标事件、绘制、命中测试UserControl
DiagramNode.cs节点模型,包含节点类型、位置、端口无
DiagramPort.cs端口模型,记录端口在节点内的相对位置DiagramNode
DiagramConnection.cs连线模型,引用节点 Id 与端口索引无
DiagramDocument.cs序列化容器,保存/加载 JSONNewtonsoft.Json
CommandManager.cs命令管理器,管理撤销/重做栈ICommand
MainForm.cs入口窗口,放置工具栏和画布DiagramCanvas

建议先把 DiagramNode、DiagramPort、DiagramConnection 这三个类读完,再进 DiagramCanvas。因为 Canvas 里所有逻辑都在操作这三个对象,先理解数据模型,再看事件就不会满眼都是坐标计算。

4.2 初始化画布与节点数据:一个最小可运行示例

启动入口在 MainForm。它做的事情很直接:创建一个 DiagramCanvas 填满窗口,然后添加两个节点和一条连线:

public sealed class MainForm : Form { private readonly DiagramCanvas _canvas; public MainForm() { Text = "类 Visio 图形拖拽示例"; WindowState = FormWindowState.Maximized; _canvas = new DiagramCanvas { Dock = DockStyle.Fill }; Controls.Add(_canvas); BuildSampleData(); } private void BuildSampleData() { var start = new DiagramNode { Id = Guid.NewGuid().ToString("N"), NodeType = "Start", Bounds = new RectangleF(30, 30, 120, 50) }; var approve = new DiagramNode { Id = Guid.NewGuid().ToString("N"), NodeType = "Approve", Bounds = new RectangleF(220, 120, 140, 70) }; _canvas.AddNode(start); _canvas.AddNode(approve); _canvas.AddConnection(start.Id, approve.Id, 0, 0); } }

运行后你会看到两个矩形节点,中间有一条曲线。按住节点拖动,连线会跟着动;从一个节点的端口拖向另一个节点的端口,松手后生成新连线。这个最小示例验证了拖拽、绘制、连通三条主链路。如果运行后界面空白,优先检查是不是没有调用 BuildSampleData,或者 OnPaint 里的 _nodes 集合为空。

4.3 依赖项与运行时注意:.NET 版本、DPI 与双缓冲

源码的序列化部分引用了 Newtonsoft.Json,NuGet 还原通常在打开解决方案时自动完成。如果你的环境无法联网还原,可以直接把 Serialize/LoadFromJson 里的 JsonConvert 换成 DataContractJsonSerializer,只保留模型层,依赖就能降到零。

运行时最需要注意的是高 DPI 显示。在 125% 或 150% 缩放的屏幕上,WinForms 默认会把鼠标坐标按系统缩放后再传给控件,而 Graphics 绘制默认又用逻辑像素,两套单位一旦错开,坐标就漂移。解决方法是给工程配置 app.manifest 并声明 PerMonitorV2 感知:

<ApplicationManifest>app.manifest</ApplicationManifest>

声明之后,窗体和画布按实际物理像素工作,鼠标坐标和绘制坐标保持一致。如果项目是从旧机器迁移过来的,还要检查主窗体的 AutoScaleMode 是否设置成了 Dpi,否则控件尺寸会被二次缩放。双缓冲方面,构造函数已经通过 SetStyle 打开 OptimizedDoubleBuffer,这里不需要额外写 BufferedGraphics。

5. 常见问题与排查:拖拽抖动、卡顿和坐标漂移的排查手册

这套代码跑起来之后,最常见的坑基本集中在坐标换算和重绘策略上。我把从实际调试中遇到的几个高频问题按「现象 → 原因 → 解决」列在下面,照着排查能省不少时间。

5.1 四个高频问题:现象、原因与解决

问题一:拖拽时节点和鼠标错位,节点跳到你点击位置的右下角。

现象:鼠标按下后节点没有出现在原位,而是整体向左上或右下偏移一截,拖拽过程中偏移量保持不变。原因:MouseDown 没用 ToCanvas,直接用了 e.Location,而节点 Bounds 是相对逻辑画布的坐标。画布没有滚动时两者一致,一旦用户滚动画布,AutoScrollPosition 不再为零,差值就暴露了。解决:所有鼠标事件先通过 ToCanvas 换算,再交给命中测试。这个函数已经在画布类里实现,统一入口后这类问题不会再出。

问题二:拖拽时画布闪烁,像老电视在换台。

现象:移动节点时背景色闪黑条,或者节点边缘出现残影。原因:构造函数里的双缓冲没生效,或者某个子控件把 ControlStyles.AllPaintingInWmPaint 关了。另一个常见原因是 OnPaint 里每次重绘都调用 base.OnPaint 后又直接清空背景,导致缓冲被强行擦除。解决:确认 SetStyle 包含 OptimizedDoubleBuffer,并且不要在 OnPaint 里调用 Graphics.Clear。如果还有少量闪烁,把 BackColor 设置成固定值,不要使用透明背景,WinForms 的透明区域重绘代价很高。

问题三:连线拖到端口旁边却吸附不上。

现象:临时连线的终点已经接近端口圆点,鼠标一松,连线消失。原因:吸附阈值和端口热区不匹配。如果 HitTestPort 的热区半径是 6 像素,SnapDistance 也是 6 像素,用户稍微偏一点点就失败。解决:端口热区半径放大到 10 到 12 像素,吸附判断用平方距离和半径平方比较,MouseUp 时再调用一次 SnapToPort 作为强制吸附。注意阈值不要太大,否则相邻端口会互相抢线。

问题四:保存后重新打开,连线端点偏移,节点和端口位置对不上。

现象:在 150% 缩放的开发机上编辑正常,放到 100% 缩放的机器上打开后,节点位置漂移,连线指向旧坐标。原因:序列化时把屏幕像素坐标当成逻辑坐标存了下来,DPI 变化后绘制坐标系变了,坐标也就漂了。解决:启用 PerMonitorV2 DPI 感知,保存前把节点 Bounds 统一换算到逻辑像素。更稳妥的做法是保存时用相对坐标:节点存相对画布左上角的逻辑坐标,端口存相对节点左上角的坐标,这样连绘制的缩放都解耦了。

问题五:删除节点后,之前连接它的连线没有消失。

现象:删掉一个节点,画布上还留着几条一端悬空的线。原因:删除操作只从 _nodes 移除节点,没有同步删除 _connections 里引用了这个节点的所有连线,也没有把相关命令从撤销栈里清掉。解决:在删除入口先收集 StartNodeId 或 EndNodeId 等于该节点 Id 的连线,全部移除后再删节点。撤销栈里如果有旧的连线命令,要单独处理,否则 Undo 时会触发空引用。

5.2 定位问题:把模型层和绘图层分开验证

很多拖拽问题看似玄学,最后都能归结到两个层面:要么模型坐标错了,要么绘制坐标错了。定位的方法很简单,在状态变化时 Dump 一份模型数据:

private void DebugDumpState() { foreach (var node in _nodes) Debug.WriteLine($"Node {node.Id}: {node.Bounds}"); foreach (var conn in _connections) Debug.WriteLine($"Conn {conn.StartNodeId}->{conn.EndNodeId}"); }

拖拽过程中如果 Model 里的 Bounds 正确、屏幕上显示的节点位置不对,问题在渲染层,重点查 Paint 的坐标转换;如果 Model 里的坐标本身就不对,问题在 MouseDown 或 ToCanvas。不要在 OnPaint 里断点,拖拽一次会触发几十次重绘,断点会直接把 IDE 卡死。用一个布尔开关只输出状态变化,比盯屏幕更高效。这个思路可以一直保留到二次开发阶段,每次新增交互都跑一遍 Dump,能避免把旧问题带进新功能。

6. 二次开发:把固定画布改造成可配置的 OA 节点编排引擎

基础画布能跑通之后,下一步就是把它从“图形 Demo”变成“业务引擎”。OA 节点拖拽的核心不是画图,而是让不同类型的节点带上不同的属性和校验规则。改造思路是把写死在代码里的节点类型抽出来,做成注册表。

6.1 节点类型注册表与属性面板联动

常见做法是定义一个节点工厂,由工厂根据 NodeType 创建指定尺寸和默认颜色的节点:

public sealed class DefaultNodeFactory { public DiagramNode CreateNode(string nodeType, PointF location) { var bounds = nodeType switch { "Start" => new RectangleF(location.X, location.Y, 120, 40), "Approve" => new RectangleF(location.X, location.Y, 140, 70), "Condition" => new RectangleF(location.X, location.Y, 160, 60), _ => new RectangleF(location.X, location.Y, 120, 50) }; return new DiagramNode { Id = Guid.NewGuid().ToString("N"), NodeType = nodeType, Bounds = bounds }; } }

工具栏上维护一个 NodeType 清单,点击按钮时调用工厂创建节点,再交给画布。这样 Canvas 不需要认识每个业务类型,只负责渲染和拖拽。属性面板联动也顺理成章:在画布上暴露 SelectionChanged 事件,属性控件拿到当前选中的 DiagramNode,根据 NodeType 动态显示字段,比如审批节点显示审批人列表,条件节点显示流转条件表达式。序列化时把这些业务字段放进一个 Dictionary<string, string> 附在节点上,加载时再原样填回属性面板。

6.2 验证方法:自动化模拟拖拽与回归测试

改完交互后,手工点按只能覆盖最简单的路径。我给画布加了一个 internal 方法,直接调用拖拽状态机,不经过真实鼠标:

_canvas.AddNode(startNode); var before = _canvas.Nodes.Count; _canvas.SimulateDrag(startNode.Id, new PointF(50, 50), new PointF(200, 200)); var after = _canvas.Nodes.Count; Debug.Assert(before == after); Debug.Assert(startNode.Bounds.X == 200 && startNode.Bounds.Y == 200);

这里断言的是:拖拽不会新增或删除节点,移动后节点位置符合预期。再叠加撤销断言:调用 Undo 后节点 Bounds 回到原始值。从此每改一次状态机,先把这套回放跑一遍,再上手工测试。以前我在画布上拖动调试,改一处漏一处,回放脚本把这类回归问题挡在了发布之前。希望这个习惯也能帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 21:14:36

Claude Code 插件开发实战:knowledge-work-plugins 拆解与工作流编排

1. 从零认识 knowledge-work-plugins&#xff1a;它到底解决什么问题第一次看到knowledge-work-plugins这个仓库名&#xff0c;很多人会以为它只是某个工具的插件合集&#xff0c;随手 clone 下来翻两眼就丢到一边。但如果你真的在 Claude Code 或 Claude Cowork 里干过一段时间…

作者头像 李华
网站建设 2026/10/18 21:14:10

期末救星实测:AI课程论文工具如何搞定大纲、写作与一键排版

期末季的课程论文&#xff0c;大概是所有大学生绕不开的一座山。它看着不难——不就是写几千字文章交上去嘛&#xff0c;可真的坐下来你会发现两座大山横在面前&#xff1a;一是对着空白文档找不到话说&#xff0c;最后只能东拼西凑、把一句话换个说法再写一遍&#xff1b;二是…

作者头像 李华
网站建设 2026/10/18 21:13:25

BrowserSkill:基于CDP的浏览器CLI调度框架

1. 项目概述&#xff1a;BrowserSkill 是什么&#xff0c;它解决的是哪类真实痛点&#xff1f;BrowserSkill 这个名字乍看像某个新出的 Chrome 插件或 Edge 扩展&#xff0c;但结合热词中反复出现的bsk、CLI、Chrome、Edge&#xff0c;以及与codex cli、zcode cli、trae cli、d…

作者头像 李华
网站建设 2026/10/18 21:09:31

黑色简洁PHP短网址系统:短码+302跳转与点击统计实战解析

简介&#xff1a;简洁的PHP短网址短链接生成源码&#xff0c;采用黑色极简视觉风格&#xff0c;面向需要部署私有短链服务的站长与PHP开发者。前端支持创建普通/自定义短链接、密码保护、链接统计、暗色主题、小书签及一键复制分享&#xff1b;后台可删除网址、编辑站点设置、添…

作者头像 李华