From 38bec44a056a899d55fe89a93ab1f68e02c02ecb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 6 Nov 2025 16:05:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=20=E5=AE=8C=E6=88=90=20Node?= =?UTF-8?q?=20Canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AuroraDesk.Core/Entities/ConnectionPoint.cs | 37 + .../Entities/ConnectorAttachmentMode.cs | 28 + .../Entities/ConnectorPlacementMode.cs | 18 + AuroraDesk.Core/Entities/Node.cs | 119 ++- AuroraDesk.Core/Entities/NodeTemplate.cs | 92 +- .../Controls/ConnectorColumnPanel.cs | 61 ++ .../Converters/NodeCanvasConverters.cs | 232 ++++- .../Pages/NodeCanvasPageViewModel.cs | 491 ++++++++- .../Views/Pages/NodeCanvasPageView.axaml | 934 +++++++++++------- .../Views/Pages/NodeCanvasPageView.axaml.cs | 444 ++++++--- NodeCanvas显示问题修复记录.md | 128 +++ modify.md | 327 ++++++ 12 files changed, 2370 insertions(+), 541 deletions(-) create mode 100644 AuroraDesk.Core/Entities/ConnectorAttachmentMode.cs create mode 100644 AuroraDesk.Core/Entities/ConnectorPlacementMode.cs create mode 100644 AuroraDesk.Presentation/Controls/ConnectorColumnPanel.cs create mode 100644 NodeCanvas显示问题修复记录.md diff --git a/AuroraDesk.Core/Entities/ConnectionPoint.cs b/AuroraDesk.Core/Entities/ConnectionPoint.cs index 07723b9..afaf5ed 100644 --- a/AuroraDesk.Core/Entities/ConnectionPoint.cs +++ b/AuroraDesk.Core/Entities/ConnectionPoint.cs @@ -8,10 +8,16 @@ namespace AuroraDesk.Core.Entities; /// 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; /// /// 连接点唯一标识符 @@ -54,6 +60,37 @@ public class ConnectionPoint : ReactiveObject set => this.RaiseAndSetIfChanged(ref _isConnected, value); } + /// + /// 附件圆直径 + /// + public double Diameter + { + get => _diameter; + set + { + var clamped = Math.Clamp(value, MinDiameter, MaxDiameter); + this.RaiseAndSetIfChanged(ref _diameter, clamped); + } + } + + /// + /// 附件圆颜色(Hex) + /// + public string Color + { + get => _color; + set => this.RaiseAndSetIfChanged(ref _color, value); + } + + /// + /// 附件圆定位(内侧/外侧) + /// + public ConnectorPlacementMode Placement + { + get => _placement; + set => this.RaiseAndSetIfChanged(ref _placement, value); + } + /// /// 所属节点 /// diff --git a/AuroraDesk.Core/Entities/ConnectorAttachmentMode.cs b/AuroraDesk.Core/Entities/ConnectorAttachmentMode.cs new file mode 100644 index 0000000..9e8da81 --- /dev/null +++ b/AuroraDesk.Core/Entities/ConnectorAttachmentMode.cs @@ -0,0 +1,28 @@ +namespace AuroraDesk.Core.Entities; + +/// +/// 节点附件圆显示模式 +/// +public enum ConnectorAttachmentMode +{ + /// + /// 不显示附件圆 + /// + None, + + /// + /// 仅显示左侧附件圆 + /// + LeftOnly, + + /// + /// 仅显示右侧附件圆 + /// + RightOnly, + + /// + /// 左右两侧都显示附件圆 + /// + Both +} + diff --git a/AuroraDesk.Core/Entities/ConnectorPlacementMode.cs b/AuroraDesk.Core/Entities/ConnectorPlacementMode.cs new file mode 100644 index 0000000..42908eb --- /dev/null +++ b/AuroraDesk.Core/Entities/ConnectorPlacementMode.cs @@ -0,0 +1,18 @@ +namespace AuroraDesk.Core.Entities; + +/// +/// 附件圆定位模式(控制在节点边框内侧或外侧渲染) +/// +public enum ConnectorPlacementMode +{ + /// + /// 在节点边框内侧对齐 + /// + Inside, + + /// + /// 在节点边框外侧对齐 + /// + Outside +} + diff --git a/AuroraDesk.Core/Entities/Node.cs b/AuroraDesk.Core/Entities/Node.cs index 1d31003..7e8203f 100644 --- a/AuroraDesk.Core/Entities/Node.cs +++ b/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; /// 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 _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; + + /// + /// 构造函数 + /// + public Node() + { + _connectionPointsChangedHandler = (_, _) => RaiseConnectionPointsChanged(); + _connectionPoints.CollectionChanged += _connectionPointsChangedHandler; + } /// /// 节点唯一标识符 @@ -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); + } } /// @@ -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); + } } /// @@ -92,7 +122,90 @@ public class Node : ReactiveObject public ObservableCollection 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(); + newCollection.CollectionChanged += _connectionPointsChangedHandler; + + this.RaiseAndSetIfChanged(ref _connectionPoints, newCollection); + RaiseConnectionPointsChanged(); + } + } + + /// + /// 左侧附件圆尺寸 + /// + public double LeftConnectorSize + { + get => _leftConnectorSize; + set + { + var clamped = Math.Clamp(value, MinConnectorSize, MaxConnectorSize); + this.RaiseAndSetIfChanged(ref _leftConnectorSize, clamped); + } + } + + /// + /// 右侧附件圆尺寸 + /// + public double RightConnectorSize + { + get => _rightConnectorSize; + set + { + var clamped = Math.Clamp(value, MinConnectorSize, MaxConnectorSize); + this.RaiseAndSetIfChanged(ref _rightConnectorSize, clamped); + } + } + + /// + /// 左侧附件圆颜色(Hex) + /// + public string LeftConnectorColor + { + get => _leftConnectorColor; + set => this.RaiseAndSetIfChanged(ref _leftConnectorColor, value); + } + + /// + /// 右侧附件圆颜色(Hex) + /// + public string RightConnectorColor + { + get => _rightConnectorColor; + set => this.RaiseAndSetIfChanged(ref _rightConnectorColor, value); + } + + /// + /// 左侧附件圆定位方式 + /// + public ConnectorPlacementMode LeftConnectorPlacement + { + get => _leftConnectorPlacement; + set => this.RaiseAndSetIfChanged(ref _leftConnectorPlacement, value); + } + + /// + /// 右侧附件圆定位方式 + /// + public ConnectorPlacementMode RightConnectorPlacement + { + get => _rightConnectorPlacement; + set => this.RaiseAndSetIfChanged(ref _rightConnectorPlacement, value); + } + private void RaiseConnectionPointsChanged() + { + this.RaisePropertyChanged(nameof(ConnectionPoints)); } } diff --git a/AuroraDesk.Core/Entities/NodeTemplate.cs b/AuroraDesk.Core/Entities/NodeTemplate.cs index fc582bd..6e5d18c 100644 --- a/AuroraDesk.Core/Entities/NodeTemplate.cs +++ b/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"; /// /// 模板唯一标识符 @@ -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); + } } /// @@ -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); + } } /// @@ -94,6 +108,60 @@ public class NodeTemplate : ReactiveObject set => this.RaiseAndSetIfChanged(ref _height, value); } + /// + /// 左侧附件圆尺寸 + /// + public double LeftConnectorSize + { + get => _leftConnectorSize; + set => this.RaiseAndSetIfChanged(ref _leftConnectorSize, value); + } + + /// + /// 右侧附件圆尺寸 + /// + public double RightConnectorSize + { + get => _rightConnectorSize; + set => this.RaiseAndSetIfChanged(ref _rightConnectorSize, value); + } + + /// + /// 左侧附件圆颜色(Hex) + /// + public string LeftConnectorColor + { + get => _leftConnectorColor; + set => this.RaiseAndSetIfChanged(ref _leftConnectorColor, value); + } + + /// + /// 右侧附件圆颜色(Hex) + /// + public string RightConnectorColor + { + get => _rightConnectorColor; + set => this.RaiseAndSetIfChanged(ref _rightConnectorColor, value); + } + + /// + /// 左侧附件圆定位 + /// + public ConnectorPlacementMode LeftConnectorPlacement + { + get => _leftConnectorPlacement; + set => this.RaiseAndSetIfChanged(ref _leftConnectorPlacement, value); + } + + /// + /// 右侧附件圆定位 + /// + public ConnectorPlacementMode RightConnectorPlacement + { + get => _rightConnectorPlacement; + set => this.RaiseAndSetIfChanged(ref _rightConnectorPlacement, value); + } + /// /// 根据模板创建节点实例 /// @@ -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 }); } diff --git a/AuroraDesk.Presentation/Controls/ConnectorColumnPanel.cs b/AuroraDesk.Presentation/Controls/ConnectorColumnPanel.cs new file mode 100644 index 0000000..7dbf64d --- /dev/null +++ b/AuroraDesk.Presentation/Controls/ConnectorColumnPanel.cs @@ -0,0 +1,61 @@ +using System; +using Avalonia; +using Avalonia.Controls; + +namespace AuroraDesk.Presentation.Controls; + +/// +/// 垂直均匀分布子元素的面板,用于渲染节点左右侧连接点。 +/// +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; + } +} + diff --git a/AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs b/AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs index c0ea34a..3d57ebd 100644 --- a/AuroraDesk.Presentation/Converters/NodeCanvasConverters.cs +++ b/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(); } /// @@ -74,15 +81,15 @@ public class BooleanToBorderThicknessConverter : IValueConverter /// /// 过滤输入连接点转换器 /// -public class FilterInputPointsConverter : IValueConverter +public class NodeToSelectionTextConverter : IValueConverter { public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - if (value is ObservableCollection points) + if (value is Node node) { - return points.Where(p => p.Type == ConnectionPointType.Input).ToList(); + return $"已选中1个对象 (节点)"; } - return Enumerable.Empty(); + return "未选中对象"; } public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -92,17 +99,57 @@ public class FilterInputPointsConverter : IValueConverter } /// -/// 过滤输出连接点转换器 +/// 非空转换器(用于控制可见性) /// -public class FilterOutputPointsConverter : IValueConverter +public class IsNotNullConverter : IValueConverter { public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - if (value is ObservableCollection points) + return value != null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +/// +/// 空值转换器(用于控制可见性) +/// +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(); + } +} + +/// +/// 将附件圆模式枚举转换为中文描述 +/// +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(); + + return string.Empty; } public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -112,17 +159,21 @@ public class FilterOutputPointsConverter : IValueConverter } /// -/// 节点到选择文本转换器 +/// 过滤节点输入连接点集合 /// -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 points) { - return $"已选中1个对象 (节点)"; + return points + .Where(p => p.Type == ConnectionPointType.Input) + .OrderBy(p => p.Index) + .ToList(); } - return "未选中对象"; + + return Array.Empty(); } public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -132,13 +183,154 @@ public class NodeToSelectionTextConverter : IValueConverter } /// -/// 非空转换器(用于控制可见性) +/// 过滤节点输出连接点集合 /// -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 points) + { + return points + .Where(p => p.Type == ConnectionPointType.Output) + .OrderBy(p => p.Index) + .ToList(); + } + + return Array.Empty(); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +/// +/// 将附件圆定位模式转换为显示文本 +/// +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(); + } +} + +/// +/// 根据附件圆定位和尺寸计算 ItemsControl 外边距 +/// +public class ConnectorPlacementMarginConverter : IMultiValueConverter +{ + public object? Convert(IList 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 values, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +/// +/// Double 与字符串互转转换器 +/// +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; + } +} + +/// +/// 将 Hex 字符串转换为 SolidColorBrush +/// +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(); + } +} + +/// +/// 将零基索引转换为显示文本(圆 1、圆 2 ...) +/// +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) diff --git a/AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs b/AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs index 7de00e8..2595cfe 100644 --- a/AuroraDesk.Presentation/ViewModels/Pages/NodeCanvasPageViewModel.cs +++ b/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? _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 Nodes => _nodeCanvasService.Nodes; public ObservableCollection Connections => _nodeCanvasService.Connections; @@ -34,13 +41,157 @@ public class NodeCanvasPageViewModel : RoutableViewModel set => this.RaiseAndSetIfChanged(ref _nodeTemplates, value); } + public IReadOnlyList ConnectorCountOptions => _connectorCountOptions; + /// /// 选中的节点 /// + 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 _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); + } } /// @@ -96,6 +247,12 @@ public class NodeCanvasPageViewModel : RoutableViewModel public ReactiveCommand DeleteConnectionCommand { get; } public ReactiveCommand<(double x, double y), Unit> AddNodeCommand { get; } public ReactiveCommand<(NodeTemplate template, double x, double y), Unit> AddNodeFromTemplateCommand { get; } + public ReactiveCommand AddNodeFromTemplateAtCenterCommand { get; } + public ReactiveCommand AddNodeToCanvasCommand { get; } + public ReactiveCommand ZoomInCommand { get; } + public ReactiveCommand ZoomOutCommand { get; } + public ReactiveCommand ResetZoomCommand { get; } + public ReactiveCommand SetZoomCommand { get; } public NodeCanvasPageViewModel( IScreen screen, @@ -116,6 +273,12 @@ public class NodeCanvasPageViewModel : RoutableViewModel DeleteConnectionCommand = ReactiveCommand.Create(DeleteConnection); AddNodeCommand = ReactiveCommand.Create<(double x, double y)>(AddNode); AddNodeFromTemplateCommand = ReactiveCommand.Create<(NodeTemplate template, double x, double y)>(AddNodeFromTemplate); + AddNodeFromTemplateAtCenterCommand = ReactiveCommand.Create(template => AddNodeFromTemplateAtCenter(template)); + AddNodeToCanvasCommand = ReactiveCommand.Create(AddNodeToCanvas); + ZoomInCommand = ReactiveCommand.Create(ZoomIn); + ZoomOutCommand = ReactiveCommand.Create(ZoomOut); + ResetZoomCommand = ReactiveCommand.Create(ResetZoom); + SetZoomCommand = ReactiveCommand.Create(SetZoom); // 监听选中节点变化 this.WhenAnyValue(x => x.SelectedNode) @@ -130,34 +293,48 @@ public class NodeCanvasPageViewModel : RoutableViewModel _logger?.LogInformation("NodeCanvasPageViewModel 已创建"); } - + /// /// 初始化节点模板 /// 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(); + } + } + } + /// /// 删除节点 /// @@ -309,11 +612,127 @@ public class NodeCanvasPageViewModel : RoutableViewModel /// 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, "添加节点时发生错误"); + } + } + + /// + /// 从模板添加节点到画布中心 + /// + 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); + } + + /// + /// 添加节点到画布(简化版本,默认位置) + /// + 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, "添加节点时发生错误"); + } + } + + /// + /// 放大 + /// + private void ZoomIn() + { + var newZoom = Math.Min(_canvasZoom * 1.2, 3.0); // 最大3倍 + CanvasZoom = newZoom; + _logger?.LogDebug("放大到: {Zoom}", newZoom); + } + + /// + /// 缩小 + /// + private void ZoomOut() + { + var newZoom = Math.Max(_canvasZoom / 1.2, 0.1); // 最小0.1倍 + CanvasZoom = newZoom; + _logger?.LogDebug("缩小到: {Zoom}", newZoom); + } + + /// + /// 重置缩放 + /// + private void ResetZoom() + { + CanvasZoom = 1.0; + _logger?.LogDebug("重置缩放到: 1.0"); + } + + /// + /// 设置缩放 + /// + 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); } } diff --git a/AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml b/AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml index 9cd5af6..e80ac5f 100644 --- a/AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml +++ b/AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml @@ -2,28 +2,40 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:vm="using:AuroraDesk.Presentation.ViewModels.Pages" + xmlns:vm="using:AuroraDesk.Presentation.ViewModels.Pages" xmlns:reactive="using:ReactiveUI.Avalonia" xmlns:entities="using:AuroraDesk.Core.Entities" xmlns:converters="using:AuroraDesk.Presentation.Converters" + xmlns:heroicons="clr-namespace:HeroIconsAvalonia.Controls;assembly=HeroIconsAvalonia" + xmlns:controls="clr-namespace:AuroraDesk.Presentation.Controls" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800" x:Class="AuroraDesk.Presentation.Views.Pages.NodeCanvasPageView" - x:DataType="vm:NodeCanvasPageViewModel"> + x:DataType="vm:NodeCanvasPageViewModel" + UseLayoutRounding="True"> - - + + + + + + + + + - + @@ -34,8 +46,10 @@ - + BorderThickness="1" + CornerRadius="8" + Margin="0,0,12,0"> + @@ -70,109 +84,63 @@ - - - - - + @@ -220,273 +188,561 @@ - - - + + + + + + + + + + + + + + + + + + + + + ClipToBounds="False"> + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - + BorderThickness="1" + CornerRadius="8" + UseLayoutRounding="True"> + + + Foreground="{StaticResource TextPrimary}"/> + Foreground="{StaticResource TextSecondary}"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + IsVisible="{Binding SelectedNode, Converter={StaticResource IsNotNullConverter}}" + UseLayoutRounding="True"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + None + LeftOnly + RightOnly + Both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inside + Outside + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inside + Outside + + + + + + + + + + + + + + + + + + diff --git a/AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs b/AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs index 1a52a15..2e15ad1 100644 --- a/AuroraDesk.Presentation/Views/Pages/NodeCanvasPageView.axaml.cs +++ b/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 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("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 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() - .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) + /// + /// 处理模板双击事件,添加节点到画布 + /// + 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? nodes) + /// + /// 更新所有节点的 Canvas 位置 + /// + private void UpdateNodePositions(ItemsControl itemsControl, System.Collections.ObjectModel.ObservableCollection nodes) { - if (_canvasContainer == null || nodes == null) return; - - // 查找 ItemsControl - var itemsControl = this.FindControl("NodesItemsControl"); - if (itemsControl == null) + if (itemsControl == null || nodes == null) return; + + foreach (var node in nodes) { - itemsControl = _canvasContainer.GetVisualDescendants() - .OfType() - .FirstOrDefault(); + UpdateSingleNodePosition(itemsControl, node); } - - if (itemsControl != null) + } + + /// + /// 订阅节点位置属性变化 + /// + private void SubscribeToNodePositionChanges(Node node) + { + if (node == null) return; + + node.WhenAnyValue(x => x.X, x => x.Y) + .Subscribe(pos => + { + var itemsControl = this.FindControl("NodesItemsControl"); + if (itemsControl != null) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + UpdateSingleNodePosition(itemsControl, node); + }, Avalonia.Threading.DispatcherPriority.Normal); + } + }); + } + + /// + /// 更新单个节点的 Canvas 位置(备用方案,主要依赖数据绑定) + /// + private void UpdateSingleNodePosition(ItemsControl itemsControl, Node node) + { + if (itemsControl == null || node == null) return; + + try { - // 强制更新 ItemsControl 的布局 - itemsControl.InvalidateMeasure(); - itemsControl.InvalidateArrange(); + var allPresenters = itemsControl.GetVisualDescendants().OfType().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().FirstOrDefault(); + if (border?.DataContext == node) { - if (item is Node node) - { - // 在 Avalonia 中,通过遍历视觉树来查找对应的容器 - var container = itemsControl.GetVisualDescendants() - .OfType() - .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 + { + // 忽略异常,位置更新主要依赖数据绑定 + } + } + + /// + /// 滚动到指定节点的位置 + /// + 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; + } } } } diff --git a/NodeCanvas显示问题修复记录.md b/NodeCanvas显示问题修复记录.md new file mode 100644 index 0000000..30126b7 --- /dev/null +++ b/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 + + + + + +``` + +**关键点**:Canvas 必须设置明确的大小,不能依赖自动计算。 + +### 2. 添加位置数据绑定 + +在 `ItemTemplate` 的根元素(`Border`)上绑定位置: + +```xml + +``` + +**关键点**:位置绑定应该在 `ItemTemplate` 的根元素上,数据绑定会自动处理位置更新。 + +### 3. ItemsControl 布局设置 + +为 `ItemsControl` 添加对齐属性: + +```xml + +``` + +### 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) + diff --git a/modify.md b/modify.md index b26cb5b..38f89a8 100644 --- a/modify.md +++ b/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日 - **问题**: