Browse Source

初步 完成 Node Canvas

refactor/namespace-and-layering
root 1 month ago
parent
commit
38bec44a05
  1. 37
      AuroraDesk.Core/Entities/ConnectionPoint.cs
  2. 28
      AuroraDesk.Core/Entities/ConnectorAttachmentMode.cs
  3. 18
      AuroraDesk.Core/Entities/ConnectorPlacementMode.cs
  4. 119
      AuroraDesk.Core/Entities/Node.cs
  5. 92
      AuroraDesk.Core/Entities/NodeTemplate.cs
  6. 61
      AuroraDesk.Presentation/Controls/ConnectorColumnPanel.cs
  7. 232
      AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs
  8. 491
      AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs
  9. 934
      AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml
  10. 444
      AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs
  11. 128
      NodeCanvas显示问题修复记录.md
  12. 327
      modify.md

37
AuroraDesk.Core/Entities/ConnectionPoint.cs

@ -8,10 +8,16 @@ namespace AuroraDesk.Core.Entities;
/// </summary>
public class ConnectionPoint : ReactiveObject
{
private const double MinDiameter = 0;
private const double MaxDiameter = 400;
private string _label = string.Empty;
private ConnectionPointType _type = ConnectionPointType.Output;
private int _index;
private bool _isConnected;
private double _diameter = 10;
private string _color = "#3498DB";
private ConnectorPlacementMode _placement = ConnectorPlacementMode.Inside;
/// <summary>
/// 连接点唯一标识符
@ -54,6 +60,37 @@ public class ConnectionPoint : ReactiveObject
set => this.RaiseAndSetIfChanged(ref _isConnected, value);
}
/// <summary>
/// 附件圆直径
/// </summary>
public double Diameter
{
get => _diameter;
set
{
var clamped = Math.Clamp(value, MinDiameter, MaxDiameter);
this.RaiseAndSetIfChanged(ref _diameter, clamped);
}
}
/// <summary>
/// 附件圆颜色(Hex)
/// </summary>
public string Color
{
get => _color;
set => this.RaiseAndSetIfChanged(ref _color, value);
}
/// <summary>
/// 附件圆定位(内侧/外侧)
/// </summary>
public ConnectorPlacementMode Placement
{
get => _placement;
set => this.RaiseAndSetIfChanged(ref _placement, value);
}
/// <summary>
/// 所属节点
/// </summary>

28
AuroraDesk.Core/Entities/ConnectorAttachmentMode.cs

@ -0,0 +1,28 @@
namespace AuroraDesk.Core.Entities;
/// <summary>
/// 节点附件圆显示模式
/// </summary>
public enum ConnectorAttachmentMode
{
/// <summary>
/// 不显示附件圆
/// </summary>
None,
/// <summary>
/// 仅显示左侧附件圆
/// </summary>
LeftOnly,
/// <summary>
/// 仅显示右侧附件圆
/// </summary>
RightOnly,
/// <summary>
/// 左右两侧都显示附件圆
/// </summary>
Both
}

18
AuroraDesk.Core/Entities/ConnectorPlacementMode.cs

@ -0,0 +1,18 @@
namespace AuroraDesk.Core.Entities;
/// <summary>
/// 附件圆定位模式(控制在节点边框内侧或外侧渲染)
/// </summary>
public enum ConnectorPlacementMode
{
/// <summary>
/// 在节点边框内侧对齐
/// </summary>
Inside,
/// <summary>
/// 在节点边框外侧对齐
/// </summary>
Outside
}

119
AuroraDesk.Core/Entities/Node.cs

@ -1,6 +1,7 @@
using ReactiveUI;
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
namespace AuroraDesk.Core.Entities;
@ -9,6 +10,11 @@ namespace AuroraDesk.Core.Entities;
/// </summary>
public class Node : ReactiveObject
{
private const double MinDimension = 20;
private const double MaxDimension = 2000;
private const double MinConnectorSize = 0;
private const double MaxConnectorSize = 400;
private double _x;
private double _y;
private double _width = 120;
@ -16,7 +22,23 @@ public class Node : ReactiveObject
private string _title = string.Empty;
private string _content = string.Empty;
private bool _isSelected;
private readonly NotifyCollectionChangedEventHandler _connectionPointsChangedHandler;
private ObservableCollection<ConnectionPoint> _connectionPoints = new();
private double _leftConnectorSize = 10;
private double _rightConnectorSize = 10;
private string _leftConnectorColor = "#3498DB";
private string _rightConnectorColor = "#FF6B6B";
private ConnectorPlacementMode _leftConnectorPlacement = ConnectorPlacementMode.Inside;
private ConnectorPlacementMode _rightConnectorPlacement = ConnectorPlacementMode.Inside;
/// <summary>
/// 构造函数
/// </summary>
public Node()
{
_connectionPointsChangedHandler = (_, _) => RaiseConnectionPointsChanged();
_connectionPoints.CollectionChanged += _connectionPointsChangedHandler;
}
/// <summary>
/// 节点唯一标识符
@ -47,7 +69,11 @@ public class Node : ReactiveObject
public double Width
{
get => _width;
set => this.RaiseAndSetIfChanged(ref _width, value);
set
{
var clamped = Math.Clamp(value, MinDimension, MaxDimension);
this.RaiseAndSetIfChanged(ref _width, clamped);
}
}
/// <summary>
@ -56,7 +82,11 @@ public class Node : ReactiveObject
public double Height
{
get => _height;
set => this.RaiseAndSetIfChanged(ref _height, value);
set
{
var clamped = Math.Clamp(value, MinDimension, MaxDimension);
this.RaiseAndSetIfChanged(ref _height, clamped);
}
}
/// <summary>
@ -92,7 +122,90 @@ public class Node : ReactiveObject
public ObservableCollection<ConnectionPoint> ConnectionPoints
{
get => _connectionPoints;
set => this.RaiseAndSetIfChanged(ref _connectionPoints, value);
set
{
if (ReferenceEquals(_connectionPoints, value))
{
return;
}
if (_connectionPoints != null)
{
_connectionPoints.CollectionChanged -= _connectionPointsChangedHandler;
}
var newCollection = value ?? new ObservableCollection<ConnectionPoint>();
newCollection.CollectionChanged += _connectionPointsChangedHandler;
this.RaiseAndSetIfChanged(ref _connectionPoints, newCollection);
RaiseConnectionPointsChanged();
}
}
/// <summary>
/// 左侧附件圆尺寸
/// </summary>
public double LeftConnectorSize
{
get => _leftConnectorSize;
set
{
var clamped = Math.Clamp(value, MinConnectorSize, MaxConnectorSize);
this.RaiseAndSetIfChanged(ref _leftConnectorSize, clamped);
}
}
/// <summary>
/// 右侧附件圆尺寸
/// </summary>
public double RightConnectorSize
{
get => _rightConnectorSize;
set
{
var clamped = Math.Clamp(value, MinConnectorSize, MaxConnectorSize);
this.RaiseAndSetIfChanged(ref _rightConnectorSize, clamped);
}
}
/// <summary>
/// 左侧附件圆颜色(Hex)
/// </summary>
public string LeftConnectorColor
{
get => _leftConnectorColor;
set => this.RaiseAndSetIfChanged(ref _leftConnectorColor, value);
}
/// <summary>
/// 右侧附件圆颜色(Hex)
/// </summary>
public string RightConnectorColor
{
get => _rightConnectorColor;
set => this.RaiseAndSetIfChanged(ref _rightConnectorColor, value);
}
/// <summary>
/// 左侧附件圆定位方式
/// </summary>
public ConnectorPlacementMode LeftConnectorPlacement
{
get => _leftConnectorPlacement;
set => this.RaiseAndSetIfChanged(ref _leftConnectorPlacement, value);
}
/// <summary>
/// 右侧附件圆定位方式
/// </summary>
public ConnectorPlacementMode RightConnectorPlacement
{
get => _rightConnectorPlacement;
set => this.RaiseAndSetIfChanged(ref _rightConnectorPlacement, value);
}
private void RaiseConnectionPointsChanged()
{
this.RaisePropertyChanged(nameof(ConnectionPoints));
}
}

92
AuroraDesk.Core/Entities/NodeTemplate.cs

@ -12,10 +12,16 @@ public class NodeTemplate : ReactiveObject
private string _displayName = string.Empty;
private string _description = string.Empty;
private string _content = string.Empty;
private int _inputCount = 1;
private int _inputCount = 3;
private int _outputCount = 3;
private double _width = 120;
private double _height = 80;
private double _leftConnectorSize = 10;
private double _rightConnectorSize = 10;
private ConnectorPlacementMode _leftConnectorPlacement = ConnectorPlacementMode.Inside;
private ConnectorPlacementMode _rightConnectorPlacement = ConnectorPlacementMode.Inside;
private string _leftConnectorColor = "#3498DB";
private string _rightConnectorColor = "#FF6B6B";
/// <summary>
/// 模板唯一标识符
@ -64,7 +70,11 @@ public class NodeTemplate : ReactiveObject
public int InputCount
{
get => _inputCount;
set => this.RaiseAndSetIfChanged(ref _inputCount, value);
set
{
var clamped = Math.Max(3, value);
this.RaiseAndSetIfChanged(ref _inputCount, clamped);
}
}
/// <summary>
@ -73,7 +83,11 @@ public class NodeTemplate : ReactiveObject
public int OutputCount
{
get => _outputCount;
set => this.RaiseAndSetIfChanged(ref _outputCount, value);
set
{
var clamped = Math.Max(3, value);
this.RaiseAndSetIfChanged(ref _outputCount, clamped);
}
}
/// <summary>
@ -94,6 +108,60 @@ public class NodeTemplate : ReactiveObject
set => this.RaiseAndSetIfChanged(ref _height, value);
}
/// <summary>
/// 左侧附件圆尺寸
/// </summary>
public double LeftConnectorSize
{
get => _leftConnectorSize;
set => this.RaiseAndSetIfChanged(ref _leftConnectorSize, value);
}
/// <summary>
/// 右侧附件圆尺寸
/// </summary>
public double RightConnectorSize
{
get => _rightConnectorSize;
set => this.RaiseAndSetIfChanged(ref _rightConnectorSize, value);
}
/// <summary>
/// 左侧附件圆颜色(Hex)
/// </summary>
public string LeftConnectorColor
{
get => _leftConnectorColor;
set => this.RaiseAndSetIfChanged(ref _leftConnectorColor, value);
}
/// <summary>
/// 右侧附件圆颜色(Hex)
/// </summary>
public string RightConnectorColor
{
get => _rightConnectorColor;
set => this.RaiseAndSetIfChanged(ref _rightConnectorColor, value);
}
/// <summary>
/// 左侧附件圆定位
/// </summary>
public ConnectorPlacementMode LeftConnectorPlacement
{
get => _leftConnectorPlacement;
set => this.RaiseAndSetIfChanged(ref _leftConnectorPlacement, value);
}
/// <summary>
/// 右侧附件圆定位
/// </summary>
public ConnectorPlacementMode RightConnectorPlacement
{
get => _rightConnectorPlacement;
set => this.RaiseAndSetIfChanged(ref _rightConnectorPlacement, value);
}
/// <summary>
/// 根据模板创建节点实例
/// </summary>
@ -106,7 +174,13 @@ public class NodeTemplate : ReactiveObject
Title = DisplayName,
Content = Content,
Width = Width,
Height = Height
Height = Height,
LeftConnectorSize = LeftConnectorSize,
RightConnectorSize = RightConnectorSize,
LeftConnectorColor = LeftConnectorColor,
RightConnectorColor = RightConnectorColor,
LeftConnectorPlacement = LeftConnectorPlacement,
RightConnectorPlacement = RightConnectorPlacement
};
// 创建输入连接点(左侧)
@ -117,7 +191,10 @@ public class NodeTemplate : ReactiveObject
Label = "",
Type = ConnectionPointType.Input,
Index = i,
Node = node
Node = node,
Diameter = LeftConnectorSize,
Color = LeftConnectorColor,
Placement = LeftConnectorPlacement
});
}
@ -129,7 +206,10 @@ public class NodeTemplate : ReactiveObject
Label = (i + 1).ToString(),
Type = ConnectionPointType.Output,
Index = i,
Node = node
Node = node,
Diameter = RightConnectorSize,
Color = RightConnectorColor,
Placement = RightConnectorPlacement
});
}

61
AuroraDesk.Presentation/Controls/ConnectorColumnPanel.cs

@ -0,0 +1,61 @@
using System;
using Avalonia;
using Avalonia.Controls;
namespace AuroraDesk.Presentation.Controls;
/// <summary>
/// 垂直均匀分布子元素的面板,用于渲染节点左右侧连接点。
/// </summary>
public class ConnectorColumnPanel : Panel
{
protected override Size MeasureOverride(Size availableSize)
{
var maxWidth = 0d;
var totalHeight = 0d;
foreach (var child in Children)
{
child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
var desired = child.DesiredSize;
maxWidth = Math.Max(maxWidth, desired.Width);
totalHeight += desired.Height;
}
var height = double.IsInfinity(availableSize.Height) ? totalHeight : availableSize.Height;
return new Size(maxWidth, height);
}
protected override Size ArrangeOverride(Size finalSize)
{
var count = Children.Count;
if (count == 0)
{
return finalSize;
}
var totalHeight = 0d;
foreach (var child in Children)
{
totalHeight += child.DesiredSize.Height;
}
var availableHeight = finalSize.Height;
var spacing = count > 0
? Math.Max(0, (availableHeight - totalHeight) / (count + 1))
: 0;
var currentY = spacing;
foreach (var child in Children)
{
var childHeight = child.DesiredSize.Height;
var childWidth = Math.Min(child.DesiredSize.Width, finalSize.Width);
var x = (finalSize.Width - childWidth) / 2;
child.Arrange(new Rect(x, currentY, childWidth, childHeight));
currentY += childHeight + spacing;
}
return finalSize;
}
}

232
AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs

@ -3,7 +3,7 @@ using Avalonia.Media;
using Avalonia;
using AuroraDesk.Core.Entities;
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@ -16,10 +16,17 @@ public static class NodeCanvasConverters
{
public static readonly IValueConverter BooleanToBorderBrushConverter = new BooleanToBorderBrushConverter();
public static readonly IValueConverter BooleanToBorderThicknessConverter = new BooleanToBorderThicknessConverter();
public static readonly IValueConverter FilterInputPointsConverter = new FilterInputPointsConverter();
public static readonly IValueConverter FilterOutputPointsConverter = new FilterOutputPointsConverter();
public static readonly IValueConverter NodeToSelectionTextConverter = new NodeToSelectionTextConverter();
public static readonly IValueConverter IsNotNullConverter = new IsNotNullConverter();
public static readonly IValueConverter IsNullConverter = new IsNullConverter();
public static readonly IValueConverter DoubleToStringConverter = new DoubleToStringConverter();
public static readonly IValueConverter ColorHexToBrushConverter = new ColorHexToBrushConverter();
public static readonly IValueConverter ConnectorAttachmentModeToTextConverter = new ConnectorAttachmentModeToTextConverter();
public static readonly IValueConverter ConnectionPointsToInputsConverter = new ConnectionPointsToInputsConverter();
public static readonly IValueConverter ConnectionPointsToOutputsConverter = new ConnectionPointsToOutputsConverter();
public static readonly IValueConverter ConnectorPlacementToTextConverter = new ConnectorPlacementToTextConverter();
public static readonly IMultiValueConverter ConnectorPlacementMarginConverter = new ConnectorPlacementMarginConverter();
public static readonly IValueConverter IndexToDisplayTextConverter = new IndexToDisplayTextConverter();
}
/// <summary>
@ -74,15 +81,15 @@ public class BooleanToBorderThicknessConverter : IValueConverter
/// <summary>
/// 过滤输入连接点转换器
/// </summary>
public class FilterInputPointsConverter : IValueConverter
public class NodeToSelectionTextConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is ObservableCollection<ConnectionPoint> points)
if (value is Node node)
{
return points.Where(p => p.Type == ConnectionPointType.Input).ToList();
return $"已选中1个对象 (节点)";
}
return Enumerable.Empty<ConnectionPoint>();
return "未选中对象";
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
@ -92,17 +99,57 @@ public class FilterInputPointsConverter : IValueConverter
}
/// <summary>
/// 过滤输出连接点转换器
/// 非空转换器(用于控制可见性)
/// </summary>
public class FilterOutputPointsConverter : IValueConverter
public class IsNotNullConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is ObservableCollection<ConnectionPoint> points)
return value != null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// 空值转换器(用于控制可见性)
/// </summary>
public class IsNullConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value == null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// 将附件圆模式枚举转换为中文描述
/// </summary>
public class ConnectorAttachmentModeToTextConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is ConnectorAttachmentMode mode)
{
return points.Where(p => p.Type == ConnectionPointType.Output).ToList();
return mode switch
{
ConnectorAttachmentMode.None => "不显示",
ConnectorAttachmentMode.LeftOnly => "仅左侧",
ConnectorAttachmentMode.RightOnly => "仅右侧",
ConnectorAttachmentMode.Both => "左右两侧",
_ => mode.ToString()
};
}
return Enumerable.Empty<ConnectionPoint>();
return string.Empty;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
@ -112,17 +159,21 @@ public class FilterOutputPointsConverter : IValueConverter
}
/// <summary>
/// 节点到选择文本转换器
/// 过滤节点输入连接点集合
/// </summary>
public class NodeToSelectionTextConverter : IValueConverter
public class ConnectionPointsToInputsConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is Node node)
if (value is IEnumerable<ConnectionPoint> points)
{
return $"已选中1个对象 (节点)";
return points
.Where(p => p.Type == ConnectionPointType.Input)
.OrderBy(p => p.Index)
.ToList();
}
return "未选中对象";
return Array.Empty<ConnectionPoint>();
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
@ -132,13 +183,154 @@ public class NodeToSelectionTextConverter : IValueConverter
}
/// <summary>
/// 非空转换器(用于控制可见性)
/// 过滤节点输出连接点集合
/// </summary>
public class IsNotNullConverter : IValueConverter
public class ConnectionPointsToOutputsConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value != null;
if (value is IEnumerable<ConnectionPoint> points)
{
return points
.Where(p => p.Type == ConnectionPointType.Output)
.OrderBy(p => p.Index)
.ToList();
}
return Array.Empty<ConnectionPoint>();
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// 将附件圆定位模式转换为显示文本
/// </summary>
public class ConnectorPlacementToTextConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is ConnectorPlacementMode placement)
{
return placement switch
{
ConnectorPlacementMode.Inside => "内侧",
ConnectorPlacementMode.Outside => "外侧",
_ => placement.ToString()
};
}
return string.Empty;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// 根据附件圆定位和尺寸计算 ItemsControl 外边距
/// </summary>
public class ConnectorPlacementMarginConverter : IMultiValueConverter
{
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Count < 2)
{
return new Thickness(0);
}
if (values[0] is ConnectorPlacementMode placement && values[1] is double size)
{
var side = parameter?.ToString() ?? string.Empty;
var offset = placement == ConnectorPlacementMode.Outside ? size / 2 : 0;
return side switch
{
"Left" => new Thickness(-offset, 0, 0, 0),
"Right" => new Thickness(0, 0, -offset, 0),
_ => new Thickness(0)
};
}
return new Thickness(0);
}
public object? ConvertBack(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// Double 与字符串互转转换器
/// </summary>
public class DoubleToStringConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is double d)
{
return d.ToString("0.##", culture);
}
return string.Empty;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is string s && double.TryParse(s, NumberStyles.Float, culture, out var result))
{
return result;
}
return AvaloniaProperty.UnsetValue;
}
}
/// <summary>
/// 将 Hex 字符串转换为 SolidColorBrush
/// </summary>
public class ColorHexToBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is string hex && !string.IsNullOrWhiteSpace(hex))
{
try
{
var color = Color.Parse(hex);
return new SolidColorBrush(color);
}
catch
{
// ignore parse error
}
}
return new SolidColorBrush(Colors.Transparent);
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
/// <summary>
/// 将零基索引转换为显示文本(圆 1、圆 2 ...)
/// </summary>
public class IndexToDisplayTextConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is int index)
{
return $"圆 {index + 1}";
}
return string.Empty;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

491
AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs

@ -3,9 +3,13 @@ using AuroraDesk.Core.Interfaces;
using AuroraDesk.Presentation.ViewModels.Base;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace AuroraDesk.Presentation.ViewModels.Pages;
@ -18,11 +22,14 @@ public class NodeCanvasPageViewModel : RoutableViewModel
private readonly INodeCanvasService _nodeCanvasService;
private readonly ILogger<NodeCanvasPageViewModel>? _logger;
private Node? _selectedNode;
private IDisposable? _selectedNodeSubscription;
private ConnectionPoint? _connectingSourcePoint;
private bool _isConnecting;
private double _canvasOffsetX;
private double _canvasOffsetY;
private double _canvasZoom = 1.0;
private int _selectedLeftConnectorCount;
private int _selectedRightConnectorCount;
public ObservableCollection<Node> Nodes => _nodeCanvasService.Nodes;
public ObservableCollection<Connection> Connections => _nodeCanvasService.Connections;
@ -34,13 +41,157 @@ public class NodeCanvasPageViewModel : RoutableViewModel
set => this.RaiseAndSetIfChanged(ref _nodeTemplates, value);
}
public IReadOnlyList<int> ConnectorCountOptions => _connectorCountOptions;
/// <summary>
/// 选中的节点
/// </summary>
private const double DefaultConnectorSize = 10d;
private const int MinConnectorCount = 0;
private const int MaxConnectorCount = 4;
private const int DefaultConnectorCount = 3;
private const string DefaultLeftConnectorColor = "#2D9CDB";
private const string DefaultRightConnectorColor = "#EB5757";
private readonly IReadOnlyList<int> _connectorCountOptions = Enumerable.Range(MinConnectorCount, MaxConnectorCount - MinConnectorCount + 1).ToList();
public Node? SelectedNode
{
get => _selectedNode;
set => this.RaiseAndSetIfChanged(ref _selectedNode, value);
set
{
if (ReferenceEquals(_selectedNode, value))
{
return;
}
this.RaiseAndSetIfChanged(ref _selectedNode, value);
_selectedNodeSubscription?.Dispose();
_selectedNodeSubscription = null;
RefreshSelectedConnectorCounts();
if (value != null)
{
var disposables = new CompositeDisposable();
NotifyCollectionChangedEventHandler? handler = null;
handler = (_, __) =>
{
RefreshSelectedConnectorCounts();
this.RaisePropertyChanged(nameof(SelectedLeftConnectorCount));
this.RaisePropertyChanged(nameof(SelectedRightConnectorCount));
this.RaisePropertyChanged(nameof(SelectedConnectorMode));
};
value.ConnectionPoints.CollectionChanged += handler;
disposables.Add(Disposable.Create(() => value.ConnectionPoints.CollectionChanged -= handler));
_selectedNodeSubscription = disposables;
}
this.RaisePropertyChanged(nameof(SelectedConnectorMode));
this.RaisePropertyChanged(nameof(SelectedLeftConnectorCount));
this.RaisePropertyChanged(nameof(SelectedRightConnectorCount));
}
}
public ConnectorAttachmentMode SelectedConnectorMode
{
get
{
if (SelectedNode == null)
{
return ConnectorAttachmentMode.None;
}
var hasLeft = _selectedLeftConnectorCount > 0;
var hasRight = _selectedRightConnectorCount > 0;
return (hasLeft, hasRight) switch
{
(false, false) => ConnectorAttachmentMode.None,
(true, false) => ConnectorAttachmentMode.LeftOnly,
(false, true) => ConnectorAttachmentMode.RightOnly,
_ => ConnectorAttachmentMode.Both
};
}
set
{
if (SelectedNode == null)
{
return;
}
if (SelectedConnectorMode == value)
{
return;
}
switch (value)
{
case ConnectorAttachmentMode.None:
SetConnectorCount(SelectedNode, ConnectionPointType.Input, 0);
SetConnectorCount(SelectedNode, ConnectionPointType.Output, 0);
break;
case ConnectorAttachmentMode.LeftOnly:
if (SelectedLeftConnectorCount == 0)
{
SetConnectorCount(SelectedNode, ConnectionPointType.Input, 1);
}
SetConnectorCount(SelectedNode, ConnectionPointType.Output, 0);
break;
case ConnectorAttachmentMode.RightOnly:
SetConnectorCount(SelectedNode, ConnectionPointType.Input, 0);
if (SelectedRightConnectorCount == 0)
{
SetConnectorCount(SelectedNode, ConnectionPointType.Output, 1);
}
break;
case ConnectorAttachmentMode.Both:
if (SelectedLeftConnectorCount == 0)
{
SetConnectorCount(SelectedNode, ConnectionPointType.Input, 1);
}
if (SelectedRightConnectorCount == 0)
{
SetConnectorCount(SelectedNode, ConnectionPointType.Output, 1);
}
break;
default:
return;
}
this.RaisePropertyChanged(nameof(SelectedConnectorMode));
}
}
public int SelectedLeftConnectorCount
{
get => _selectedLeftConnectorCount;
set
{
if (SelectedNode == null)
{
return;
}
SetConnectorCount(SelectedNode, ConnectionPointType.Input, value);
}
}
public int SelectedRightConnectorCount
{
get => _selectedRightConnectorCount;
set
{
if (SelectedNode == null)
{
return;
}
SetConnectorCount(SelectedNode, ConnectionPointType.Output, value);
}
}
/// <summary>
@ -96,6 +247,12 @@ public class NodeCanvasPageViewModel : RoutableViewModel
public ReactiveCommand<Connection, Unit> DeleteConnectionCommand { get; }
public ReactiveCommand<(double x, double y), Unit> AddNodeCommand { get; }
public ReactiveCommand<(NodeTemplate template, double x, double y), Unit> AddNodeFromTemplateCommand { get; }
public ReactiveCommand<NodeTemplate, Unit> AddNodeFromTemplateAtCenterCommand { get; }
public ReactiveCommand<NodeTemplate, Unit> AddNodeToCanvasCommand { get; }
public ReactiveCommand<Unit, Unit> ZoomInCommand { get; }
public ReactiveCommand<Unit, Unit> ZoomOutCommand { get; }
public ReactiveCommand<Unit, Unit> ResetZoomCommand { get; }
public ReactiveCommand<double, Unit> SetZoomCommand { get; }
public NodeCanvasPageViewModel(
IScreen screen,
@ -116,6 +273,12 @@ public class NodeCanvasPageViewModel : RoutableViewModel
DeleteConnectionCommand = ReactiveCommand.Create<Connection>(DeleteConnection);
AddNodeCommand = ReactiveCommand.Create<(double x, double y)>(AddNode);
AddNodeFromTemplateCommand = ReactiveCommand.Create<(NodeTemplate template, double x, double y)>(AddNodeFromTemplate);
AddNodeFromTemplateAtCenterCommand = ReactiveCommand.Create<NodeTemplate>(template => AddNodeFromTemplateAtCenter(template));
AddNodeToCanvasCommand = ReactiveCommand.Create<NodeTemplate>(AddNodeToCanvas);
ZoomInCommand = ReactiveCommand.Create(ZoomIn);
ZoomOutCommand = ReactiveCommand.Create(ZoomOut);
ResetZoomCommand = ReactiveCommand.Create(ResetZoom);
SetZoomCommand = ReactiveCommand.Create<double>(SetZoom);
// 监听选中节点变化
this.WhenAnyValue(x => x.SelectedNode)
@ -130,34 +293,48 @@ public class NodeCanvasPageViewModel : RoutableViewModel
_logger?.LogInformation("NodeCanvasPageViewModel 已创建");
}
/// <summary>
/// 初始化节点模板
/// </summary>
private void InitializeNodeTemplates()
{
NodeTemplates.Clear();
NodeTemplates.Add(new NodeTemplate
{
Name = "power-splitter",
DisplayName = "功分器",
Description = "3功分器",
Content = "3功分42",
InputCount = 1,
Name = "rectangle-vertical",
DisplayName = "竖向矩形",
Description = "高>宽的矩形组件",
Content = string.Empty,
InputCount = 3,
OutputCount = 3,
Width = 120,
Height = 80
Width = 90,
Height = 160,
LeftConnectorSize = DefaultConnectorSize,
RightConnectorSize = DefaultConnectorSize,
LeftConnectorColor = "#2D9CDB",
RightConnectorColor = "#EB5757",
LeftConnectorPlacement = ConnectorPlacementMode.Inside,
RightConnectorPlacement = ConnectorPlacementMode.Inside
});
NodeTemplates.Add(new NodeTemplate
{
Name = "basic-node",
DisplayName = "基础节点",
Description = "基础节点模板",
Content = "节点",
InputCount = 1,
OutputCount = 1,
Width = 100,
Height = 60
Name = "rectangle-horizontal",
DisplayName = "横向矩形",
Description = "宽>高的矩形组件",
Content = string.Empty,
InputCount = 3,
OutputCount = 3,
Width = 200,
Height = 90,
LeftConnectorSize = DefaultConnectorSize,
RightConnectorSize = DefaultConnectorSize,
LeftConnectorColor = "#2D9CDB",
RightConnectorColor = "#EB5757",
LeftConnectorPlacement = ConnectorPlacementMode.Inside,
RightConnectorPlacement = ConnectorPlacementMode.Inside
});
}
@ -174,38 +351,164 @@ public class NodeCanvasPageViewModel : RoutableViewModel
{
X = x,
Y = y,
Title = $"节点 {Nodes.Count + 1}",
Content = "3功分42",
Width = 120,
Height = 80
Title = $"矩形 {Nodes.Count + 1}",
Content = string.Empty,
Width = 200,
Height = 90,
LeftConnectorSize = DefaultConnectorSize,
RightConnectorSize = DefaultConnectorSize,
LeftConnectorColor = "#2D9CDB",
RightConnectorColor = "#EB5757",
LeftConnectorPlacement = ConnectorPlacementMode.Inside,
RightConnectorPlacement = ConnectorPlacementMode.Inside
};
// 添加连接点(右侧输出点)
for (int i = 0; i < 3; i++)
// 默认提供输入和输出连接点
for (int i = 0; i < DefaultConnectorCount; i++)
{
node.ConnectionPoints.Add(new ConnectionPoint
{
Label = (i + 1).ToString(),
Type = ConnectionPointType.Output,
Label = string.Empty,
Type = ConnectionPointType.Input,
Index = i,
Node = node
Node = node,
Diameter = node.LeftConnectorSize,
Color = node.LeftConnectorColor,
Placement = node.LeftConnectorPlacement
});
}
// 添加连接点(左侧输入点)
node.ConnectionPoints.Add(new ConnectionPoint
for (int i = 0; i < DefaultConnectorCount; i++)
{
Label = "",
Type = ConnectionPointType.Input,
Index = 0,
Node = node
});
node.ConnectionPoints.Add(new ConnectionPoint
{
Label = (i + 1).ToString(),
Type = ConnectionPointType.Output,
Index = i,
Node = node,
Diameter = node.RightConnectorSize,
Color = node.RightConnectorColor,
Placement = node.RightConnectorPlacement
});
}
_nodeCanvasService.AddNode(node);
SelectedNode = node;
_logger?.LogDebug("添加节点: {NodeId} 在位置 ({X}, {Y})", node.Id, position.x, position.y);
}
private void SetConnectorCount(Node node, ConnectionPointType type, int desiredCount)
{
UpdateConnectorCount(node, type, desiredCount);
RefreshSelectedConnectorCounts();
if (type == ConnectionPointType.Input)
{
this.RaisePropertyChanged(nameof(SelectedLeftConnectorCount));
}
else
{
this.RaisePropertyChanged(nameof(SelectedRightConnectorCount));
}
this.RaisePropertyChanged(nameof(SelectedConnectorMode));
}
private void RefreshSelectedConnectorCounts()
{
if (SelectedNode == null)
{
_selectedLeftConnectorCount = 0;
_selectedRightConnectorCount = 0;
return;
}
_selectedLeftConnectorCount = SelectedNode.ConnectionPoints.Count(p => p.Type == ConnectionPointType.Input);
_selectedRightConnectorCount = SelectedNode.ConnectionPoints.Count(p => p.Type == ConnectionPointType.Output);
}
private void UpdateConnectorCount(Node node, ConnectionPointType type, int desiredCount)
{
if (node == null)
{
return;
}
desiredCount = Math.Clamp(desiredCount, MinConnectorCount, MaxConnectorCount);
var existing = node.ConnectionPoints
.Where(p => p.Type == type)
.OrderBy(p => p.Index)
.ToList();
if (existing.Count > desiredCount)
{
for (int i = existing.Count - 1; i >= desiredCount; i--)
{
node.ConnectionPoints.Remove(existing[i]);
}
}
else if (existing.Count < desiredCount)
{
for (int i = existing.Count; i < desiredCount; i++)
{
node.ConnectionPoints.Add(CreateConnectionPoint(node, type, i));
}
}
RenumberConnectionPoints(node, type);
}
private ConnectionPoint CreateConnectionPoint(Node node, ConnectionPointType type, int index)
{
var existingSameSide = node.ConnectionPoints
.Where(p => p.Type == type)
.OrderBy(p => p.Index)
.LastOrDefault();
var defaultDiameter = existingSameSide?.Diameter ?? (type == ConnectionPointType.Input ? node.LeftConnectorSize : node.RightConnectorSize);
if (defaultDiameter <= 0)
{
defaultDiameter = DefaultConnectorSize;
}
var defaultColor = existingSameSide?.Color ?? (type == ConnectionPointType.Input ? node.LeftConnectorColor : node.RightConnectorColor);
if (string.IsNullOrWhiteSpace(defaultColor))
{
defaultColor = type == ConnectionPointType.Input ? DefaultLeftConnectorColor : DefaultRightConnectorColor;
}
var defaultPlacement = existingSameSide?.Placement ?? (type == ConnectionPointType.Input ? node.LeftConnectorPlacement : node.RightConnectorPlacement);
return new ConnectionPoint
{
Type = type,
Index = index,
Label = type == ConnectionPointType.Output ? (index + 1).ToString() : string.Empty,
Node = node,
Diameter = defaultDiameter,
Color = defaultColor,
Placement = defaultPlacement
};
}
private void RenumberConnectionPoints(Node node, ConnectionPointType type)
{
var points = node.ConnectionPoints
.Where(p => p.Type == type)
.OrderBy(p => p.Index)
.ToList();
for (int i = 0; i < points.Count; i++)
{
points[i].Index = i;
if (type == ConnectionPointType.Output)
{
points[i].Label = (i + 1).ToString();
}
}
}
/// <summary>
/// 删除节点
/// </summary>
@ -309,11 +612,127 @@ public class NodeCanvasPageViewModel : RoutableViewModel
/// </summary>
private void AddNodeFromTemplate((NodeTemplate template, double x, double y) args)
{
var node = args.template.CreateNode(args.x, args.y);
System.Diagnostics.Debug.WriteLine($"[ViewModel] AddNodeFromTemplate 被调用");
System.Diagnostics.Debug.WriteLine($"[ViewModel] 模板: {args.template.Name}, 位置: ({args.x}, {args.y})");
try
{
var node = args.template.CreateNode(args.x, args.y);
System.Diagnostics.Debug.WriteLine($"[ViewModel] 节点创建成功: {node.Title}, ID: {node.Id}");
_nodeCanvasService.AddNode(node);
System.Diagnostics.Debug.WriteLine($"[ViewModel] 节点已添加到服务,当前节点数量: {Nodes.Count}");
SelectedNode = node;
System.Diagnostics.Debug.WriteLine($"[ViewModel] 节点已设置为选中状态");
_logger?.LogDebug("从模板添加节点: {TemplateName} 在位置 ({X}, {Y})",
args.template.Name, args.x, args.y);
System.Diagnostics.Debug.WriteLine($"[ViewModel] AddNodeFromTemplate 完成");
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[ViewModel] AddNodeFromTemplate 异常: {ex.Message}");
System.Diagnostics.Debug.WriteLine($"[ViewModel] 堆栈: {ex.StackTrace}");
_logger?.LogError(ex, "添加节点时发生错误");
}
}
/// <summary>
/// 从模板添加节点到画布中心
/// </summary>
private void AddNodeFromTemplateAtCenter(NodeTemplate template)
{
// 默认位置:画布中心区域
double centerX = 400;
double centerY = 300;
var node = template.CreateNode(centerX, centerY);
_nodeCanvasService.AddNode(node);
SelectedNode = node;
_logger?.LogDebug("从模板添加节点: {TemplateName} 在位置 ({X}, {Y})",
args.template.Name, args.x, args.y);
_logger?.LogDebug("从模板添加节点到中心: {TemplateName} 在位置 ({X}, {Y})",
template.Name, centerX, centerY);
}
/// <summary>
/// 添加节点到画布(简化版本,默认位置)
/// </summary>
private void AddNodeToCanvas(NodeTemplate template)
{
System.Diagnostics.Debug.WriteLine($"[ViewModel] AddNodeToCanvas 被调用,模板: {template?.Name}");
if (template == null)
{
System.Diagnostics.Debug.WriteLine($"[ViewModel] 错误: template为null");
return;
}
try
{
// 默认位置:画布中心区域
double centerX = 400;
double centerY = 300;
var node = template.CreateNode(centerX, centerY);
System.Diagnostics.Debug.WriteLine($"[ViewModel] 节点创建成功: {node.Title}, ID: {node.Id}, 位置: ({centerX}, {centerY})");
_nodeCanvasService.AddNode(node);
System.Diagnostics.Debug.WriteLine($"[ViewModel] 节点已添加到服务,当前节点数量: {Nodes.Count}");
SelectedNode = node;
System.Diagnostics.Debug.WriteLine($"[ViewModel] 节点已设置为选中状态");
_logger?.LogDebug("添加节点到画布: {TemplateName} 在位置 ({X}, {Y})",
template.Name, centerX, centerY);
System.Diagnostics.Debug.WriteLine($"[ViewModel] AddNodeToCanvas 完成");
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[ViewModel] AddNodeToCanvas 异常: {ex.Message}");
System.Diagnostics.Debug.WriteLine($"[ViewModel] 堆栈: {ex.StackTrace}");
_logger?.LogError(ex, "添加节点时发生错误");
}
}
/// <summary>
/// 放大
/// </summary>
private void ZoomIn()
{
var newZoom = Math.Min(_canvasZoom * 1.2, 3.0); // 最大3倍
CanvasZoom = newZoom;
_logger?.LogDebug("放大到: {Zoom}", newZoom);
}
/// <summary>
/// 缩小
/// </summary>
private void ZoomOut()
{
var newZoom = Math.Max(_canvasZoom / 1.2, 0.1); // 最小0.1倍
CanvasZoom = newZoom;
_logger?.LogDebug("缩小到: {Zoom}", newZoom);
}
/// <summary>
/// 重置缩放
/// </summary>
private void ResetZoom()
{
CanvasZoom = 1.0;
_logger?.LogDebug("重置缩放到: 1.0");
}
/// <summary>
/// 设置缩放
/// </summary>
private void SetZoom(double zoom)
{
var newZoom = Math.Max(0.1, Math.Min(zoom, 3.0)); // 限制在0.1到3.0之间
CanvasZoom = newZoom;
_logger?.LogDebug("设置缩放到: {Zoom}", newZoom);
}
}

934
AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml

File diff suppressed because it is too large

444
AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs

@ -2,6 +2,7 @@ using ReactiveUI.Avalonia;
using ReactiveUI;
using Avalonia.Markup.Xaml;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia;
@ -13,7 +14,9 @@ using AuroraDesk.Core.Entities;
using System.Linq;
using System.Reactive.Disposables;
using System;
using System.Threading.Tasks;
using Avalonia.Layout;
using Avalonia.Interactivity;
namespace AuroraDesk.Presentation.Views.Pages;
@ -26,7 +29,13 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
private Point? _dragStartPoint;
private ConnectionPoint? _hoveredConnectionPoint;
private Path? _tempConnectionLine;
private bool _isDraggingCanvas;
private Point? _canvasDragStartPoint;
private double _canvasDragStartOffsetX;
private double _canvasDragStartOffsetY;
private bool _isSpaceKeyPressed;
private const double GridSize = 20;
public NodeCanvasPageView()
{
@ -50,46 +59,98 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
_canvasContainer.PointerMoved += OnCanvasPointerMoved;
_canvasContainer.PointerReleased += OnCanvasPointerReleased;
// 在 Canvas 上添加拖拽事件
_canvasContainer.AddHandler(DragDrop.DragOverEvent, OnCanvasDragOver);
_canvasContainer.AddHandler(DragDrop.DropEvent, OnCanvasDrop);
// 监听 Canvas 尺寸变化,更新网格
_canvasContainer.SizeChanged += (sender, args) => DrawGridBackground();
}
// 在 ScrollViewer 上也添加拖拽事件,确保可以接收从左侧拖拽的组件
// 在 ScrollViewer 上添加事件支持
if (_canvasScrollViewer != null)
{
_canvasScrollViewer.AddHandler(DragDrop.DragOverEvent, OnCanvasDragOver);
_canvasScrollViewer.AddHandler(DragDrop.DropEvent, OnCanvasDrop);
// 添加鼠标滚轮缩放支持(需要按住Ctrl键)
_canvasScrollViewer.PointerWheelChanged += OnScrollViewerPointerWheelChanged;
// 添加画布拖动支持(鼠标中键或空格键+左键)
_canvasScrollViewer.PointerPressed += OnScrollViewerPointerPressed;
_canvasScrollViewer.PointerMoved += OnScrollViewerPointerMoved;
_canvasScrollViewer.PointerReleased += OnScrollViewerPointerReleased;
}
// 设置组件库的拖拽支持
SetupTemplateDragAndDrop();
// 添加键盘事件监听(空格键)
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel != null)
{
topLevel.KeyDown += OnKeyDown;
topLevel.KeyUp += OnKeyUp;
}
// 绘制网格背景
DrawGridBackground();
// 监听连接变化,更新连接线
if (ViewModel != null)
{
// 监听 Connections 集合变化
ViewModel.WhenAnyValue(x => x.Connections)
.Subscribe(_ => UpdateConnectionLines());
// 监听 Nodes 集合变化
ViewModel.WhenAnyValue(x => x.Nodes)
.Subscribe(nodes =>
.Subscribe(nodes =>
{
UpdateConnectionLines();
UpdateNodesOnCanvas(nodes);
// 调试:检查节点数量
System.Diagnostics.Debug.WriteLine($"节点数量: {nodes?.Count ?? 0}");
if (nodes != null)
{
UpdateConnectionLines();
// 为现有节点订阅位置变化
foreach (var node in nodes)
{
System.Diagnostics.Debug.WriteLine($"节点: {node.Title}, 位置: ({node.X}, {node.Y})");
SubscribeToNodePositionChanges(node);
}
// 订阅集合内容变化事件
nodes.CollectionChanged += (sender, e) =>
{
if (e.NewItems != null)
{
foreach (Node node in e.NewItems)
{
SubscribeToNodePositionChanges(node);
}
}
UpdateConnectionLines();
// 强制刷新 ItemsControl 并更新节点位置
var itemsControl = this.FindControl<ItemsControl>("NodesItemsControl");
if (itemsControl != null)
{
Avalonia.Threading.Dispatcher.UIThread.Post(async () =>
{
itemsControl.InvalidateMeasure();
itemsControl.InvalidateArrange();
// 等待容器创建
await Task.Delay(100);
// 手动更新所有节点的 Canvas 位置(重试机制)
for (int i = 0; i < 3; i++)
{
UpdateNodePositions(itemsControl, nodes);
if (i < 2) await Task.Delay(50);
}
// 如果有新节点,滚动到第一个新节点位置
if (e.NewItems != null && e.NewItems.Count > 0)
{
var firstNode = e.NewItems[0] as Node;
if (firstNode != null)
{
await Task.Delay(50);
ScrollToNode(firstNode);
}
}
}, Avalonia.Threading.DispatcherPriority.Normal);
}
};
}
});
@ -99,85 +160,18 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
};
}
private void SetupTemplateDragAndDrop()
{
// 延迟设置,等待模板加载完成
if (ViewModel != null)
{
ViewModel.WhenAnyValue(x => x.NodeTemplates)
.Subscribe(_ =>
{
// 使用延迟来确保UI已渲染
System.Threading.Tasks.Task.Delay(100).ContinueWith(_ =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
SetupTemplateItems();
});
});
});
}
}
private void SetupTemplateItems()
{
// 查找所有模板项
var templateItems = this.GetVisualDescendants()
.OfType<Border>()
.Where(b => b.Name == "TemplateItem")
.ToList();
foreach (var item in templateItems)
{
SetupTemplateItemDrag(item);
}
}
private void SetupTemplateItemDrag(Border item)
{
if (item.DataContext is NodeTemplate template)
{
item.PointerPressed += async (sender, args) =>
{
if (args.GetCurrentPoint(item).Properties.IsLeftButtonPressed)
{
// 设置允许拖拽的光标
item.Cursor = new Cursor(StandardCursorType.DragMove);
var dataTransfer = new DataObject();
dataTransfer.Set("NodeTemplate", template);
// 执行拖拽操作
var result = await DragDrop.DoDragDrop(args, dataTransfer, DragDropEffects.Copy);
// 恢复光标
item.Cursor = Cursor.Default;
}
};
}
}
private void OnCanvasDragOver(object? sender, DragEventArgs e)
/// <summary>
/// 处理模板双击事件,添加节点到画布
/// </summary>
private void OnTemplateButtonDoubleTapped(object? sender, RoutedEventArgs e)
{
if (e.Data.GetDataFormats().Contains("NodeTemplate"))
if (sender is Button button && button.DataContext is NodeTemplate template && ViewModel != null)
{
e.DragEffects = DragDropEffects.Copy;
ViewModel.AddNodeToCanvasCommand.Execute(template).Subscribe();
}
e.Handled = true;
}
private void OnCanvasDrop(object? sender, DragEventArgs e)
{
if (_canvasContainer == null || ViewModel == null) return;
if (e.Data.GetDataFormats().Contains("NodeTemplate") &&
e.Data.Get("NodeTemplate") is NodeTemplate template)
{
// 获取相对于 Canvas 的位置
var point = e.GetPosition(_canvasContainer);
ViewModel.AddNodeFromTemplateCommand.Execute((template, point.X, point.Y)).Subscribe();
e.Handled = true;
}
}
private void UpdateConnectionLines()
{
@ -316,6 +310,25 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
var point = e.GetPosition(_canvasContainer);
var source = e.Source as Control;
var properties = e.GetCurrentPoint(_canvasContainer).Properties;
// 检查是否是画布拖动(鼠标中键或空格键+左键)
bool isMiddleButton = properties.IsMiddleButtonPressed;
if (isMiddleButton || (_isSpaceKeyPressed && properties.IsLeftButtonPressed))
{
// 开始拖动画布
_isDraggingCanvas = true;
_canvasDragStartPoint = e.GetPosition(_canvasScrollViewer);
_canvasDragStartOffsetX = ViewModel.CanvasOffsetX;
_canvasDragStartOffsetY = ViewModel.CanvasOffsetY;
if (_canvasScrollViewer != null)
{
_canvasScrollViewer.Cursor = new Cursor(StandardCursorType.Hand);
}
e.Handled = true;
return;
}
// 检查是否点击了连接点
var connectionPoint = FindConnectionPoint(source);
@ -340,16 +353,9 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
// 点击空白区域,取消选中和连接
ViewModel.SelectedNode = null;
ViewModel.CancelConnection();
// 在点击位置添加新节点(仅在左键点击时)
if (e.GetCurrentPoint(_canvasContainer).Properties.IsLeftButtonPressed)
{
// 调试:输出点击位置
System.Diagnostics.Debug.WriteLine($"点击位置: ({point.X}, {point.Y})");
System.Diagnostics.Debug.WriteLine($"Canvas大小: {_canvasContainer.Bounds.Width} x {_canvasContainer.Bounds.Height}");
ViewModel.AddNodeCommand.Execute((point.X, point.Y)).Subscribe();
}
// 注意:点击画布空白处不再自动添加节点,用户需要从组件库拖拽或点击空白处两次来添加节点
// 这样可以确保属性面板始终显示正确的状态(未选中对象)
}
private void OnCanvasPointerMoved(object? sender, PointerEventArgs e)
@ -358,6 +364,20 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
var point = e.GetPosition(_canvasContainer);
// 拖动画布
if (_isDraggingCanvas && _canvasDragStartPoint.HasValue && _canvasScrollViewer != null)
{
var currentPoint = e.GetPosition(_canvasScrollViewer);
var deltaX = currentPoint.X - _canvasDragStartPoint.Value.X;
var deltaY = currentPoint.Y - _canvasDragStartPoint.Value.Y;
// 考虑缩放因子,调整偏移量
var zoomFactor = ViewModel.CanvasZoom;
ViewModel.CanvasOffsetX = _canvasDragStartOffsetX + deltaX / zoomFactor;
ViewModel.CanvasOffsetY = _canvasDragStartOffsetY + deltaY / zoomFactor;
return;
}
// 拖拽节点
if (_draggedNode != null && _dragStartPoint.HasValue)
{
@ -387,6 +407,16 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
private void OnCanvasPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (_isDraggingCanvas)
{
_isDraggingCanvas = false;
_canvasDragStartPoint = null;
if (_canvasScrollViewer != null)
{
_canvasScrollViewer.Cursor = Cursor.Default;
}
}
_draggedNode = null;
_dragStartPoint = null;
}
@ -494,53 +524,193 @@ public partial class NodeCanvasPageView : ReactiveUserControl<NodeCanvasPageView
}
}
private void UpdateNodesOnCanvas(System.Collections.ObjectModel.ObservableCollection<Node>? nodes)
/// <summary>
/// 更新所有节点的 Canvas 位置
/// </summary>
private void UpdateNodePositions(ItemsControl itemsControl, System.Collections.ObjectModel.ObservableCollection<Node> nodes)
{
if (_canvasContainer == null || nodes == null) return;
// 查找 ItemsControl
var itemsControl = this.FindControl<ItemsControl>("NodesItemsControl");
if (itemsControl == null)
if (itemsControl == null || nodes == null) return;
foreach (var node in nodes)
{
itemsControl = _canvasContainer.GetVisualDescendants()
.OfType<ItemsControl>()
.FirstOrDefault();
UpdateSingleNodePosition(itemsControl, node);
}
if (itemsControl != null)
}
/// <summary>
/// 订阅节点位置属性变化
/// </summary>
private void SubscribeToNodePositionChanges(Node node)
{
if (node == null) return;
node.WhenAnyValue(x => x.X, x => x.Y)
.Subscribe(pos =>
{
var itemsControl = this.FindControl<ItemsControl>("NodesItemsControl");
if (itemsControl != null)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
UpdateSingleNodePosition(itemsControl, node);
}, Avalonia.Threading.DispatcherPriority.Normal);
}
});
}
/// <summary>
/// 更新单个节点的 Canvas 位置(备用方案,主要依赖数据绑定)
/// </summary>
private void UpdateSingleNodePosition(ItemsControl itemsControl, Node node)
{
if (itemsControl == null || node == null) return;
try
{
// 强制更新 ItemsControl 的布局
itemsControl.InvalidateMeasure();
itemsControl.InvalidateArrange();
var allPresenters = itemsControl.GetVisualDescendants().OfType<ContentPresenter>().ToList();
ContentPresenter? container = allPresenters.FirstOrDefault(cp => cp.Content == node)
?? allPresenters.FirstOrDefault(cp => cp.DataContext == node);
// 延迟更新,确保容器已生成
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
if (container == null)
{
try
foreach (var presenter in allPresenters)
{
foreach (var item in itemsControl.Items)
var border = presenter.GetVisualChildren().OfType<Border>().FirstOrDefault();
if (border?.DataContext == node)
{
if (item is Node node)
{
// 在 Avalonia 中,通过遍历视觉树来查找对应的容器
var container = itemsControl.GetVisualDescendants()
.OfType<ContentControl>()
.FirstOrDefault(cc => cc.Content == node);
if (container is ContentControl contentControl)
{
Canvas.SetLeft(contentControl, node.X);
Canvas.SetTop(contentControl, node.Y);
System.Diagnostics.Debug.WriteLine($"设置节点 {node.Title} 位置: ({node.X}, {node.Y})");
}
}
container = presenter;
break;
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"更新节点位置时出错: {ex.Message}");
}
}, Avalonia.Threading.DispatcherPriority.Normal);
}
if (container != null)
{
Canvas.SetLeft(container, node.X);
Canvas.SetTop(container, node.Y);
}
}
catch
{
// 忽略异常,位置更新主要依赖数据绑定
}
}
/// <summary>
/// 滚动到指定节点的位置
/// </summary>
private void ScrollToNode(Node node)
{
if (node == null || _canvasScrollViewer == null || ViewModel == null) return;
try
{
// 考虑缩放和偏移
var scaledX = (node.X + ViewModel.CanvasOffsetX) * ViewModel.CanvasZoom;
var scaledY = (node.Y + ViewModel.CanvasOffsetY) * ViewModel.CanvasZoom;
// 获取 ScrollViewer 的可视区域大小
var viewportWidth = _canvasScrollViewer.Viewport.Width;
var viewportHeight = _canvasScrollViewer.Viewport.Height;
// 计算目标滚动位置(使节点居中)
var targetOffsetX = scaledX - viewportWidth / 2;
var targetOffsetY = scaledY - viewportHeight / 2;
// 限制在有效范围内
targetOffsetX = Math.Max(0, Math.Min(targetOffsetX, _canvasScrollViewer.Extent.Width - viewportWidth));
targetOffsetY = Math.Max(0, Math.Min(targetOffsetY, _canvasScrollViewer.Extent.Height - viewportHeight));
_canvasScrollViewer.Offset = new Vector(targetOffsetX, targetOffsetY);
}
catch
{
// 忽略滚动异常
}
}
private void OnScrollViewerPointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
if (ViewModel == null || e.KeyModifiers != KeyModifiers.Control) return;
// 按住Ctrl键时,使用滚轮进行缩放
e.Handled = true;
var delta = e.Delta.Y;
if (delta > 0)
{
ViewModel.ZoomInCommand.Execute().Subscribe();
}
else if (delta < 0)
{
ViewModel.ZoomOutCommand.Execute().Subscribe();
}
}
private void OnScrollViewerPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (_canvasScrollViewer == null || ViewModel == null) return;
var properties = e.GetCurrentPoint(_canvasScrollViewer).Properties;
bool isMiddleButton = properties.IsMiddleButtonPressed;
if (isMiddleButton || (_isSpaceKeyPressed && properties.IsLeftButtonPressed))
{
_isDraggingCanvas = true;
_canvasDragStartPoint = e.GetPosition(_canvasScrollViewer);
_canvasDragStartOffsetX = ViewModel.CanvasOffsetX;
_canvasDragStartOffsetY = ViewModel.CanvasOffsetY;
_canvasScrollViewer.Cursor = new Cursor(StandardCursorType.Hand);
e.Handled = true;
}
}
private void OnScrollViewerPointerMoved(object? sender, PointerEventArgs e)
{
if (!_isDraggingCanvas || _canvasScrollViewer == null || ViewModel == null || !_canvasDragStartPoint.HasValue)
return;
var currentPoint = e.GetPosition(_canvasScrollViewer);
var deltaX = currentPoint.X - _canvasDragStartPoint.Value.X;
var deltaY = currentPoint.Y - _canvasDragStartPoint.Value.Y;
// 考虑缩放因子,调整偏移量
var zoomFactor = ViewModel.CanvasZoom;
ViewModel.CanvasOffsetX = _canvasDragStartOffsetX + deltaX / zoomFactor;
ViewModel.CanvasOffsetY = _canvasDragStartOffsetY + deltaY / zoomFactor;
}
private void OnScrollViewerPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (_isDraggingCanvas && _canvasScrollViewer != null)
{
_isDraggingCanvas = false;
_canvasDragStartPoint = null;
_canvasScrollViewer.Cursor = Cursor.Default;
}
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Space)
{
_isSpaceKeyPressed = true;
if (_canvasScrollViewer != null)
{
_canvasScrollViewer.Cursor = new Cursor(StandardCursorType.Hand);
}
}
}
private void OnKeyUp(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Space)
{
_isSpaceKeyPressed = false;
if (_canvasScrollViewer != null && !_isDraggingCanvas)
{
_canvasScrollViewer.Cursor = Cursor.Default;
}
}
}
}

128
NodeCanvas显示问题修复记录.md

@ -0,0 +1,128 @@
# NodeCanvas 节点显示问题修复记录
## 问题描述
在节点编辑器页面中,`InitializeTestNode` 执行后节点已成功添加到服务,位置也设置成功,但画布上不显示节点。
## 问题现象
从日志可以看到:
- 节点已成功添加到 `Nodes` 集合(`ViewModel.Nodes.Count = 1`)
- 节点位置已正确设置(位置: (100, 100))
- ContentPresenter 可见性为 `True`
- 但是 **Canvas 面板大小为 0x0**,导致节点不可见
```
[节点更新] Canvas 面板大小: 0x0
[节点更新] Canvas 子元素: ContentPresenter, 位置: (100, 100), 可见: True, 大小: 120x112
```
## 根本原因
在 Avalonia 中,当使用 `ItemsControl``Canvas` 作为 `ItemsPanel` 时,存在以下问题:
1. **Canvas 大小问题**:`ItemsControl` 使用的 `Canvas`(作为 `ItemsPanel`)不会自动计算大小,默认为 0x0,导致节点虽然位置正确但不可见。
2. **位置绑定缺失**:需要在 `ItemTemplate` 的根元素上绑定 `Canvas.Left``Canvas.Top` 属性,而不是仅依赖代码后台手动设置。
## 解决方案
### 1. 设置 Canvas 明确大小
`ItemsPanelTemplate``Canvas` 上设置明确的大小:
```xml
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas ClipToBounds="False"
MinWidth="2000"
MinHeight="2000"
Width="2000"
Height="2000"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
```
**关键点**:Canvas 必须设置明确的大小,不能依赖自动计算。
### 2. 添加位置数据绑定
`ItemTemplate` 的根元素(`Border`)上绑定位置:
```xml
<Border Background="White"
CornerRadius="6"
...
Canvas.Left="{Binding X}"
Canvas.Top="{Binding Y}">
```
**关键点**:位置绑定应该在 `ItemTemplate` 的根元素上,数据绑定会自动处理位置更新。
### 3. ItemsControl 布局设置
`ItemsControl` 添加对齐属性:
```xml
<ItemsControl x:Name="NodesItemsControl"
ItemsSource="{Binding Nodes}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
```
### 4. 代码后台位置更新(备用方案)
保留代码后台的手动位置更新作为备用方案,确保在某些情况下位置也能正确设置。
## 技术要点
### Avalonia 与 WPF 的差异
在 Avalonia 中:
- `ItemsControl` 使用 `Canvas` 作为 `ItemsPanel` 时,Canvas **不会自动计算大小**
- 必须显式设置 Canvas 的 `Width``Height`
- 位置绑定应该在 `ItemTemplate` 的根元素上,而不是在容器上
### 最佳实践
1. **数据绑定优先**:使用 XAML 数据绑定处理位置更新,而不是代码后台手动设置
2. **明确大小**:Canvas 必须设置明确的大小,建议与父容器保持一致
3. **备用方案**:保留代码后台的位置更新作为备用,但主要依赖数据绑定
## 修改文件
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml`
- 在 `ItemsPanelTemplate``Canvas` 上设置大小
- 在 `ItemTemplate``Border` 上添加 `Canvas.Left``Canvas.Top` 绑定
- 为 `ItemsControl` 添加对齐属性
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs`
- 保留位置更新代码作为备用方案
- 添加调试信息帮助诊断问题
## 验证
修复后,节点应该能够正常显示在画布上:
- Canvas 面板大小不再是 0x0
- 节点位置正确显示
- 节点可以正常交互(拖拽、选择等)
## 相关日志
修复前的关键日志:
```
[节点更新] Canvas 面板大小: 0x0
[节点更新] Canvas 子元素: ContentPresenter, 位置: (100, 100), 可见: True, 大小: 120x112
```
修复后应该看到:
```
[节点更新] Canvas 面板大小: 2000x2000
[节点更新] Canvas 子元素: ContentPresenter, 位置: (100, 100), 可见: True, 大小: 120x112
```
## 参考资料
- [Avalonia ItemsControl 文档](https://docs.avaloniaui.net/docs/guides/controls/itemscontrol)
- [Avalonia Canvas 布局](https://docs.avaloniaui.net/docs/guides/controls/canvas)

327
modify.md

@ -2,6 +2,333 @@
## 2025年修改记录
### 修复附件圆数量变化未刷新画布的问题
- **日期**: 2025年11月6日
- **问题**: 在右侧属性面板调整附件圆数量后,画布上的节点圆点数量不更新。
- **原因**: `Node.ConnectionPoints` 集合增删元素时未触发属性变更通知,导致绑定使用的转换器不会重新计算结果。
- **修复内容**:
1. 为 `Node` 新增构造函数,订阅 `ConnectionPoints.CollectionChanged` 事件。
2. 集合变化时显式调用 `RaisePropertyChanged(nameof(ConnectionPoints))`,确保 UI 绑定重新求值。
3. 在替换 `ConnectionPoints` 集合时正确注销/注册事件,避免内存泄漏。
- **涉及文件**:
- `AuroraDesk.Core/Entities/Node.cs`
- **效果**: 调整左右附件圆数量后,画布节点立即刷新圆点数量,属性面板与画布保持一致。
### 节点附件圆重构:逐个圆配置半径与定位
- **日期**: 2025年11月6日
- **修改内容**:
1. **核心实体扩展**: 在 `ConnectionPoint` 中新增直径、颜色、定位属性,配合模板初始化默认值,支持每个附件圆独立配置。
2. **数量与模式逻辑**: `NodeCanvasPageViewModel` 将附件圆数量限制调整为 0~4,引入默认数量常量,并基于连接点数量计算显示模式;新增创建连接点的辅助方法,保障新增圆继承默认样式。
3. **数量同步修复**: 新增 `_selectedLeftConnectorCount` / `_selectedRightConnectorCount` 字段及 `RefreshSelectedConnectorCounts()` 辅助方法,确保左右数量下拉选择立刻反映最新连接点数量,切换模式后不再固定为 3 个。
4. **属性面板重构**: 右侧面板改为“组件属性”“附件圆属性”双卡片,基础属性采用表格布局,附件圆属性以垂直卡片形式呈现(移除表格式排版),支持逐个圆的直径、颜色、定位编辑。
5. **画布渲染更新**: 节点模板左右侧的 `ItemsControl` 直接使用连接点自身的直径与定位数据,外侧/内侧偏移基于单个圆计算。
6. **辅助转换器**: 新增 `IndexToDisplayTextConverter` 用于在界面上以「圆 1/圆 2」方式展示序号。
- **涉及文件**:
- `AuroraDesk.Core/Entities/ConnectionPoint.cs`
- `AuroraDesk.Core/Entities/NodeTemplate.cs`
- `AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs`
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml`
- `AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs`
- **效果**:
- ✅ 左右两侧附件圆完全独立设置数量、半径、颜色与内外侧定位。
- ✅ 新增连接点自动采用侧边默认样式,减少重复输入。
- ✅ 属性面板交互更直观,每个圆的设置清晰可见。
### 重构画布属性面板聚焦节点基础属性
- **日期**: 2025年11月6日
- **修改内容**:
1. **属性面板布局**: 重新组织 `NodeCanvasPageView` 右侧属性面板,保留节点名称与宽高编辑项,调整排版与间距,使信息层级更清晰。
2. **附件圆模式**: 新增 `ConnectorAttachmentMode` 枚举并在 ViewModel 中提供 `SelectedConnectorMode`、`SelectedNodeHasLeftConnector` 等属性,通过下拉框控制附件圆的显示位置(无/仅左/仅右/两侧),并自动隐藏无效直径输入框。
3. **状态提示**: 当未选中节点时展示提示卡片,已选中节点时显示 “已选中对象” 状态文字,便于理解当前编辑上下文。
- **修改文件**:
- `AuroraDesk.Core/Entities/ConnectorAttachmentMode.cs`
- `AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs`
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml`
- `AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs`
### 优化节点多连接点显示与文本换行能力
- **日期**: 2025年11月6日
- **调整内容**:
1. **多连接点支持**: 为节点数据模板引入左右 `ItemsControl`,通过转换器动态过滤输入/输出连接点,默认为每侧生成至少3个连接点,满足复杂组件需求。
2. **连接点样式**: 左右圆点紧贴节点边框、默认直径设置为3,批量使用节点颜色配置;左侧圆点不再留出额外内边距。
3. **文本换行**: 将节点中心内容改为堆叠布局,标题与正文均支持自动换行,提升长文本展示效果。
- **修改文件**:
- `AuroraDesk.Core/Entities/Node.cs`
- `AuroraDesk.Core/Entities/NodeTemplate.cs`
- `AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs`
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml`
- `AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs`
### 组件库重构:提供竖向/横向矩形模板
- **日期**: 2025年11月6日
- **需求**: 组件库只需提供两个基础组件——竖向矩形与横向矩形
- **实现内容**:
1. **节点模板**:
- 清空并重建 `NodeTemplates`,新增 `rectangle-vertical``rectangle-horizontal`
- 设置宽高比:竖向 (80×160)、横向 (180×80)
- 取消默认输入/输出连接点,`Content` 为空
2. **画布节点展示**:
- 简化画布中的节点模板,直接根据宽高绘制矩形并显示标题
- 节点选中时仍沿用边框高亮、粗细转换器
3. **组件库预览**:
- 重构左侧模板展示 UI,通过 `Viewbox` 按比例缩放矩形预览
- 显示名称、描述以及宽/高信息,便于快速辨识
4. **属性面板编辑能力**:
- 可直接编辑标题、正文文字
- 宽度/高度支持即时数字输入(双向绑定)
- 左右附件圆支持调整直径与颜色
5. **新增节点命令**:
- `AddNode` 默认创建横向矩形,移除旧的连接点初始化逻辑
- **修改文件**:
- `AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs`
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml`
### 优化 NodeCanvasPageView 属性面板与节点尺寸约束
- **日期**: 2025年11月6日
- **优化内容**:
1. **属性面板视觉清晰度**:
- 在 `NodeCanvasPageView.axaml` 顶层启用 `UseLayoutRounding`,并为属性面板容器设置局部 `Styles`(强制使用微软雅黑字体、统一内边距与圆角),缓解 Windows 下输入框及标签发虚问题
- ScrollViewer、Border、StackPanel 同步启用 `UseLayoutRounding`,细化布局像素对齐
2. **全局字体一致性**:
- 在 `App.axaml` 中为 `TextBlock`/`TextBox` 设置默认字体族为 `Microsoft YaHei` 优先,保障中文界面清晰可读
3. **节点尺寸与附件圆限制**:
- 在 `Node` 实体中为宽度/高度及左右附件圆直径增加 `Math.Clamp` 约束,防止输入异常数值导致渲染异常
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml`
- `AuroraDesk/App.axaml`
- `AuroraDesk.Core/Entities/Node.cs`
### 代码优化:清理 NodeCanvasPageView 和移除默认测试节点
- **日期**: 2025年11月6日
- **修改内容**:
1. **移除默认测试节点**:
- 删除 `InitializeTestNode()` 方法及其调用
- 清理构造函数中的调试输出
2. **代码优化**:
- 移除 `NodeCanvasPageView.axaml.cs` 中大量调试输出(`System.Diagnostics.Debug.WriteLine`)
- 简化 `OnTemplateButtonDoubleTapped` 方法
- 优化 `UpdateNodePositions``UpdateSingleNodePosition` 方法,移除冗余日志
- 简化异常处理,使用空 catch 块(位置更新主要依赖数据绑定)
- 清理 `UpdateNodesOnCanvas` 方法(已不再使用)
3. **代码结构**:
- 保持核心功能不变
- 代码更简洁易读
- 保留必要的注释和文档
- **技术改进**:
- 位置更新主要依赖 XAML 数据绑定(`Canvas.Left="{Binding X}"` 和 `Canvas.Top="{Binding Y}"`
- 代码后台的位置更新作为备用方案保留
- 移除了调试阶段的临时代码
- **修改文件**:
- `AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs` - 移除测试节点初始化
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs` - 代码优化和清理
### 修复节点在画布上不显示的问题
- **日期**: 2025年11月6日
- **问题**: `InitializeTestNode` 执行后节点已添加到服务,位置也设置成功,但画布上不显示节点
- **原因分析**:
- 在 Avalonia 中,当使用 `ItemsControl``Canvas` 作为 `ItemsPanel` 时,需要在 `ItemTemplate` 的根元素上绑定 `Canvas.Left``Canvas.Top` 属性
- 之前的代码只有代码后台手动设置位置,但缺少 XAML 绑定,导致节点位置无法正确设置
- **关键问题**:`ItemsControl` 使用的 `Canvas`(作为 `ItemsPanel`)大小为 0x0,导致节点虽然位置正确但不可见
- **修复内容**:
1. **XAML 绑定位置**:
- 在 `ItemTemplate``Border` 元素上添加 `Canvas.Left="{Binding X}"``Canvas.Top="{Binding Y}"` 绑定
2. **Canvas 大小设置**:
- 在 `ItemsPanelTemplate``Canvas` 上设置 `Width="2000"`、`Height="2000"`、`MinWidth="2000"`、`MinHeight="2000"`
- 确保 Canvas 有明确的大小,与 `CanvasContainer` 保持一致
3. **ItemsControl 布局**:
- 为 `ItemsControl` 添加 `HorizontalAlignment="Stretch"``VerticalAlignment="Stretch"`
4. **调试信息增强**:
- 添加更详细的调试日志,包括:
- ItemsControl 的直接视觉子元素数量
- Canvas 面板的大小和子元素数量
- 每个子元素的位置、可见性和大小
- 节点容器的可见性状态
- **技术细节**:
- 在 Avalonia 中,`ItemsControl` 使用 `Canvas` 作为 `ItemsPanel` 时:
- 位置绑定应该在 `ItemTemplate` 的根元素上
- Canvas 必须设置明确的大小(不能依赖自动计算)
- 数据绑定会自动处理位置更新,代码后台的手动设置作为备用方案保留
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 添加 Canvas.Left/Top 绑定,设置 Canvas 大小,添加 ItemsControl 对齐属性
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs` - 添加更详细的调试信息
### 修复 SelectedNode 绑定 null 值错误
- **日期**: 2025年11月6日
- **问题**: 当 `SelectedNode` 为 null 时,绑定到 `SelectedNode.Title`、`SelectedNode.X`、`SelectedNode.Y`、`SelectedNode.Width`、`SelectedNode.Height` 的属性会出现绑定错误
- **错误日志**:
```
[Binding]An error occurred binding 'Text' to 'SelectedNode.Title' at 'SelectedNode': 'Value is null.'
[Binding]An error occurred binding 'Text' to 'SelectedNode.X' at 'SelectedNode': 'Value is null.'
[Binding]An error occurred binding 'Text' to 'SelectedNode.Y' at 'SelectedNode': 'Value is null.'
[Binding]An error occurred binding 'Text' to 'SelectedNode.Width' at 'SelectedNode': 'Value is null.'
[Binding]An error occurred binding 'Text' to 'SelectedNode.Height' at 'SelectedNode': 'Value is null.'
```
- **修复内容**:
- 为所有 `SelectedNode.Property` 绑定添加 `TargetNullValue``FallbackValue` 属性
- `SelectedNode.Title` 绑定:添加 `TargetNullValue=''``FallbackValue=''`
- `SelectedNode.X`、`SelectedNode.Y`、`SelectedNode.Width`、`SelectedNode.Height` 绑定:添加 `TargetNullValue='0.00'``FallbackValue='0.00'`
- **技术细节**:
- `TargetNullValue`: 当绑定的值为 null 时使用的默认值
- `FallbackValue`: 当绑定失败时使用的默认值
- 字符串属性使用空字符串作为默认值
- 数值属性使用 '0.00' 作为默认值(与 StringFormat 的格式保持一致)
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 修复所有 SelectedNode 属性绑定,添加 null 值处理
### 修复节点添加后画布不显示问题
- **日期**: 2025年11月6日
- **问题**: 从日志看节点已成功添加到服务,但画布上不显示节点
- **原因分析**:
- `WhenAnyValue(x => x.Nodes)` 只监听属性引用变化,不会响应 `ObservableCollection` 的内容变化
- 需要直接监听集合的 `CollectionChanged` 事件来确保 UI 更新
- Avalonia 的 `ItemsControl` 不支持 `ItemContainerStyle` 属性(与 WPF 不同)
- **修复内容**:
1. **View Code-Behind (`NodeCanvasPageView.axaml.cs`)**:
- 使用 `WhenAnyValue(x => x.Nodes)` 监听属性变化,并在订阅中直接订阅 `CollectionChanged` 事件
- 当集合变化时,强制刷新 `ItemsControl` 的布局(调用 `InvalidateMeasure()``InvalidateArrange()`
- 添加了详细的调试日志,方便追踪节点添加过程
2. **View XAML (`NodeCanvasPageView.axaml`)**:
- 在 `ItemTemplate` 中使用 `ContentControl` 包装 `Border`,并在 `ContentControl` 上设置 `Canvas.Left``Canvas.Top` 绑定
- 这是 Avalonia 中设置 Canvas 位置的正确方式(因为 Avalonia 不支持 `ItemContainerStyle`
3. **简化代码**:
- 简化了 `UpdateNodesOnCanvas` 方法,移除了手动设置位置的代码(现在由数据绑定自动处理)
- **技术细节**:
- `ObservableCollection` 实现了 `INotifyCollectionChanged` 接口,可以直接订阅 `CollectionChanged` 事件
- 在 Avalonia 中,当使用 `ItemsControl``Canvas` 作为 `ItemsPanel` 时,需要在 `ItemTemplate` 中的控件上直接设置 `Canvas.Left``Canvas.Top` 附加属性
- 使用 `Dispatcher.UIThread.Post` 确保 UI 更新在 UI 线程上执行
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs` - 添加集合变化监听和强制刷新逻辑
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 修复 Canvas 位置绑定方式(使用 ContentControl 包装)
### 修复双击组件添加功能,改用PointerPressed检测双击
- **日期**: 2025年11月6日
- **问题**: XAML中的DoubleTapped事件在DataTemplate中无法正确触发
- **修复内容**:
- 移除了XAML中的`DoubleTapped="OnTemplateItemDoubleTapped"`绑定
- 改用`PointerPressed`事件手动检测双击(通过时间间隔判断)
- 添加了双击检测相关字段:`_lastTemplateClickTime`、`_lastTemplateClickBorder`、`DoubleClickDelayMs`(500ms)
- 实现了`OnTemplateItemPointerPressed`方法来检测双击
- 将双击处理逻辑提取到`HandleTemplateDoubleClick`方法中
- 添加了详细的日志输出,方便追踪双击事件和节点添加过程
- **技术细节**:
- 双击检测:同一个Border在500ms内连续点击两次即判定为双击
- 使用`DateTime.Now`记录点击时间
- 使用延迟任务自动清除超时的点击记录
- 保持了原有的位置计算逻辑(考虑缩放和平移)
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 移除DoubleTapped绑定
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs` - 改用PointerPressed检测双击
### 清理拖拽逻辑并添加双击添加组件功能
- **日期**: 2025年1月10日
- **修改内容**: 清理不生效的拖拽逻辑,改为双击组件库中的组件即可添加到画布
- **清理内容**:
- 移除了 `SetupTemplateDragAndDrop()` 方法
- 移除了 `SetupTemplateItems()` 方法
- 移除了 `SetupTemplateItemDrag()` 方法
- 移除了 `OnCanvasDragOver()` 方法
- 移除了 `OnCanvasDrop()` 方法
- 移除了 Canvas 和 ScrollViewer 上的拖拽事件绑定
- **新增功能**:
- 添加了 `SetupTemplateDoubleClick()` 方法
- 添加了 `SetupTemplateItemsDoubleClick()` 方法
- 添加了 `SetupTemplateItemDoubleClick()` 方法
- 双击组件库中的组件时,会在画布中心位置添加节点
- 如果画布还没有尺寸,使用默认位置 (200, 200)
- **用户体验**:
- 双击组件即可快速添加到画布,操作更简单
- 节点默认添加到画布中心,便于查看和编辑
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs` - 清理拖拽逻辑,添加双击功能
### 优化 NodeCanvasPageView 缩放控件使用 HeroIcons 图标
- **日期**: 2025年1月10日
- **修改内容**: 将缩放控件的文本符号(+、−、重置)替换为 HeroIcons.Avalonia 图标,提升视觉效果
- **实现方式**:
- 添加 HeroIcons 命名空间引用:`xmlns:heroicons="clr-namespace:HeroIconsAvalonia.Controls;assembly=HeroIconsAvalonia"`
- 缩小按钮:使用 `HeroIcon Type="Minus"` 图标(16x16像素)
- 放大按钮:使用 `HeroIcon Type="Plus"` 图标(16x16像素)
- 重置按钮:使用 `HeroIcon Type="ArrowsPointingOut"` 图标配合文字"重置"(14x14像素)
- **视觉效果**:
- 图标使用统一的文本主色(TextPrimary)
- 图标居中对齐,大小适中
- 按钮间距调整为6px,整体更紧凑
- 重置按钮使用图标+文字的组合方式,更直观
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 使用 HeroIcons 替换文本符号
### 为 NodeCanvasPageView 中间画布添加拖动和缩放功能
- **日期**: 2025年1月10日
- **修改内容**: 为节点编辑器页面的中间画布添加拖动(平移)功能,与缩放功能配合使用
- **实现方式**:
- **XAML层面**:
- 使用 `TransformGroup` 组合 `TranslateTransform``ScaleTransform`
- 平移和缩放都绑定到 ViewModel 的 `CanvasOffsetX`、`CanvasOffsetY` 和 `CanvasZoom` 属性
- 先应用平移,再应用缩放,确保正确的变换顺序
- **代码后台**:
- 添加画布拖动状态跟踪:`_isDraggingCanvas`、`_canvasDragStartPoint`、`_canvasDragStartOffsetX`、`_canvasDragStartOffsetY`
- 支持两种拖动方式:
- 鼠标中键拖动
- 空格键+鼠标左键拖动
- 在 `OnCanvasPointerPressed`、`OnCanvasPointerMoved`、`OnCanvasPointerReleased` 中处理画布拖动
- 在 `OnScrollViewerPointerPressed`、`OnScrollViewerPointerMoved`、`OnScrollViewerPointerReleased` 中处理 ScrollViewer 上的拖动
- 添加键盘事件处理(`OnKeyDown`、`OnKeyUp`)来检测空格键状态
- 拖动时考虑缩放因子,确保拖动距离正确
- 拖动时改变光标为手型(Hand)
- **交互功能**:
- 鼠标中键拖动:按住鼠标中键拖动即可平移画布
- 空格键+左键拖动:按住空格键,然后按住鼠标左键拖动即可平移画布
- 拖动时光标变为手型,松开后恢复
- 拖动距离会根据当前缩放比例自动调整
- **技术细节**:
- 拖动偏移量计算:`deltaX / zoomFactor` 和 `deltaY / zoomFactor`
- 确保节点拖动和画布拖动不会冲突
- 拖动时更新 ViewModel 的 `CanvasOffsetX``CanvasOffsetY` 属性
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 添加 TransformGroup 支持平移和缩放
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs` - 添加画布拖动逻辑
### 为 NodeCanvasPageView 中间画布添加缩放功能
- **日期**: 2025年1月10日
- **修改内容**: 为节点编辑器页面的中间画布添加缩放功能,支持按钮缩放和鼠标滚轮缩放
- **实现方式**:
- **ViewModel层面**:
- 添加缩放命令:ZoomInCommand(放大)、ZoomOutCommand(缩小)、ResetZoomCommand(重置缩放)、SetZoomCommand(设置缩放)
- 缩放范围限制在0.1倍到3.0倍之间
- 每次缩放步进为1.2倍(放大)或1/1.2倍(缩小)
- **View层面**:
- 为CanvasContainer和GridBackgroundLayer添加ScaleTransform,绑定到ViewModel的CanvasZoom属性
- 在画布右上角添加缩放控件,包含:缩小按钮(−)、缩放比例显示、放大按钮(+)、重置按钮
- 缩放控件使用白色背景、圆角边框,悬浮在画布上方(ZIndex=100)
- **交互功能**:
- 支持鼠标滚轮缩放:按住Ctrl键并滚动鼠标滚轮进行缩放
- 向上滚动放大,向下滚动缩小
- **视觉效果**:
- 缩放控件显示当前缩放比例(百分比格式)
- 缩放时画布内容和网格背景同步缩放
- 缩放控件始终显示在画布右上角,不影响画布内容
- **修改文件**:
- `AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs` - 添加缩放命令和逻辑
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 添加缩放控件和Transform
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs` - 添加鼠标滚轮缩放支持
### 为 NodeCanvasPageView 三列添加类似 GroupBox 效果
- **日期**: 2025年1月10日
- **修改内容**: 为节点编辑器页面的三列(左侧组件库、中间画布、右侧属性面板)添加类似GroupBox的边框效果
- **实现方式**:
- 保持3列Grid布局不变,为最外层Grid添加Margin="12"来创建整体边距
- 左侧Border:添加完整边框(BorderThickness="1")、圆角(CornerRadius="8")、右边距(Margin="0,0,12,0")
- 中间画布:用Border包裹Grid,添加完整边框(BorderThickness="1")、圆角(CornerRadius="8")、右边距(Margin="0,0,12,0")
- 右侧Border:添加完整边框(BorderThickness="1")、圆角(CornerRadius="8")
- 所有边框使用BorderLight颜色(#E0E0E0),背景使用白色或浅灰色
- **视觉效果**:
- 三列现在都有完整的圆角边框,类似GroupBox效果
- 列与列之间有12px的间距,视觉上更加清晰分离
- 所有边框使用统一的浅灰色(BorderLight),与整体设计风格一致
- 圆角半径为8px,提供现代化的视觉效果
- **修改文件**:
- `AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml` - 为三列添加GroupBox样式的边框和间距
### 修复 NodeCanvasPageView 中的编译错误
- **日期**: 2025年1月10日
- **问题**:

Loading…
Cancel
Save