Skip to content
miroiu edited this page Jun 22, 2024 · 1 revision

了解组件的命名方式及其在编辑器可视化树中的角色对于理解代码和文档非常重要。

层次结构和术语

根组件是一个编辑器(editor),它包含节点(nodes)连接(connections)以及一些额外的UI元素,如选择框(selection rectangle)和一个预备连接(pending connection),以使编辑器具有交互性。

节点是连接器(connectors)的容器,有时候节点本身也可以作为连接器(比如 状态节点).

连接器可以创建预备连接,预备连接在完成后可以成为实际的连接。

一图胜千言

nodes-hierarchy

内容层

你可能会好奇,一个节点如何既能作为连接器本身又能像普通节点一样运行。编辑器包含三个主要层次,这些层次有助于解决这个问题:

  1. 项目层(NodifyEditor.ItemsSource)——在这里,每个控件都被包装在一个容器中,使其可以选择、拖动等,并且可以渲染任何控件(例如连接器、文本块)
  2. 连接层(NodifyEditor.Connections)——这是所有连接共存的地方,并默认在项目层下面渲染。
  3. 装饰层(NodifyEditor.Decorators)——在这里,每个控件在窗口中都有一个位置。

将这些层次分开,使得每个层次可以异步加载成为可能。

使用现有主题

将以下其中一个主题合并到 App.xaml 中的资源字典中:

  • 深色主题(如果未指定,则为默认主题):
<ResourceDictionary Source="pack://application:,,,/Nodify;component/Themes/Dark.xaml" />
  • 浅色主题:
<ResourceDictionary Source="pack://application:,,,/Nodify;component/Themes/Light.xaml" />
  • Nodify主题:
<ResourceDictionary Source="pack://application:,,,/Nodify;component/Themes/Nodify.xaml" />

一个小案例

导入 nodify 命名空间:xmlns:nodify="https://miroiu.github.io/nodify"xmlns:nodify="clr-namespace:Nodify;assembly=Nodify" 到你的文件中,并创建一个编辑器实例 <nodify:NodifyEditor />。如果你启动应用程序,你会看到一个可以创建选择矩形的空白区域。

提示:将选择矩形拖动到编辑器区域的边缘附近,屏幕将自动向该方向移动。

添加节点(nodes)

现在我们将显示一些节点。让我们创建视图模型并将它们绑定到视图。

public class NodeViewModel
{
    public string Title { get; set; }
}

public class EditorViewModel
{
    public ObservableCollection<NodeViewModel> Nodes { get; } = new ObservableCollection<NodeViewModel>();

    public EditorViewModel()
    {
        Nodes.Add(new NodeViewModel { Title = "Welcome" });
    }
}

视图模型可以是任何形状,但节点的视图由 ItemTemplate 生成。(将 DataTemplate 放在 NodifyEditor.Resources 中也能实现相同的效果)

<nodify:NodifyEditor ItemsSource="{Binding Nodes}">
    <nodify:NodifyEditor.DataContext>
        <local:EditorViewModel />
    </nodify:NodifyEditor.DataContext>

    <nodify:NodifyEditor.ItemTemplate>
        <DataTemplate DataType="{x:Type local:NodeViewModel}">
            <nodify:Node Header="{Binding Title}" />
        </DataTemplate>
    </nodify:NodifyEditor.ItemTemplate>
    
</nodify:NodifyEditor>

请注意,我们绑定 Node 的 Header 属性来显示 Title。要了解更多节点类型和自定义,请查看节点概述

连接节点(nodes)

好的,现在让我们添加更多节点并将它们连接起来。首先,我们需要一个连接器的表示以及节点上一些集合来存储我们的连接器。

public class ConnectorViewModel
{
    public string Title { get; set; }
}

public class NodeViewModel
{
    public string Title { get; set; }

    public ObservableCollection<ConnectorViewModel> Input { get; set; } = new ObservableCollection<ConnectorViewModel>();
    public ObservableCollection<ConnectorViewModel> Output { get; set; } = new ObservableCollection<ConnectorViewModel>();
}

public class EditorViewModel
{
    public ObservableCollection<NodeViewModel> Nodes { get; } = new ObservableCollection<NodeViewModel>();

    public EditorViewModel()
    {
        Nodes.Add(new NodeViewModel
        {
            Title = "Welcome",
            Input = new ObservableCollection<ConnectorViewModel>
            {
                new ConnectorViewModel
                {
                    Title = "In"
                }
            },
            Output = new ObservableCollection<ConnectorViewModel>
            {
                new ConnectorViewModel
                {
                    Title = "Out"
                }
            }
        });
    }
}

然后将它们绑定到视图。(我们使用了内置的 NodeInputNodeOutput 作为视图,但也有其他连接器。或者根据需要创建自己的连接器。)

<nodify:Node Header="{Binding Title}"
             Input="{Binding Input}"
             Output="{Binding Output}">
  <nodify:Node.InputConnectorTemplate>
      <DataTemplate DataType="{x:Type local:ConnectorViewModel}">
          <nodify:NodeInput Header="{Binding Title}" />
      </DataTemplate>
  </nodify:Node.InputConnectorTemplate>
  
  <nodify:Node.OutputConnectorTemplate>
      <DataTemplate DataType="{x:Type local:ConnectorViewModel}">
          <nodify:NodeOutput Header="{Binding Title}" />
      </DataTemplate>
  </nodify:Node.OutputConnectorTemplate>
</nodify:Node>

Node 控件支持 InputOutput 连接器,您可以通过重写 InputConnectorTemplateOutputConnectorTemplate 的默认模板来自定义这些连接器。

InputOutput 连接器点击并拖动一根线将创建一个预备连接,我们可以将其转换为实际连接。

Nodify 最复杂的部分是如何将连接绑定到它们的连接器。 让我们为连接创建 ViewModel,并在 EditorViewModel 中添加连接列表。

public class ConnectionViewModel
{
    public ConnectorViewModel Source { get; set; }
    public ConnectorViewModel Target { get; set; }
}

public class EditorViewModel
{
    public ObservableCollection<NodeViewModel> Nodes { get; } = new ObservableCollection<NodeViewModel>();
    public ObservableCollection<ConnectionViewModel> Connections { get; } = new ObservableCollection<ConnectionViewModel>();

    public EditorViewModel()
    {
        var welcome = new NodeViewModel
        {
            Title = "Welcome",
            Input = new ObservableCollection<ConnectorViewModel>
            {
                new ConnectorViewModel
                {
                    Title = "In"
                }
            },
            Output = new ObservableCollection<ConnectorViewModel>
            {
                new ConnectorViewModel
                {
                    Title = "Out"
                }
            }
        };

        var nodify = new NodeViewModel
        {
            Title = "To Nodify",
            Input = new ObservableCollection<ConnectorViewModel>
            {
                new ConnectorViewModel
                {
                    Title = "In"
                }
            }
        };

        Nodes.Add(welcome);
        Nodes.Add(nodify);

        Connections.Add(new ConnectionViewModel
        {
            Source = welcome.Output[0],
            Target = nodify.Input[0]
        });
    }
}

然后更新 ConnectorViewModel 以具有连接可以附加的 Anchor 点。(这需要是响应式的,因此我们将在视图模型中实现 INotifyPropertyChanged 接口)。

注意:Point 类型必须来自 System.Windows。

public class ConnectorViewModel : INotifyPropertyChanged
{
    private Point _anchor;
    public Point Anchor
    {
        set
        {
            _anchor = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Anchor)));
        }
        get => _anchor;
    }

    public string Title { get; set; }

    public event PropertyChangedEventHandler PropertyChanged;
}

Anchor 绑定到连接器的视图,并设置 Mode=OneWayToSource。还需将 IsConnected 设置为 True 以接收 Anchor 更新。

<nodify:Node.InputConnectorTemplate>
    <DataTemplate DataType="{x:Type local:ConnectorViewModel}">
        <nodify:NodeInput Header="{Binding Title}"
                          IsConnected="True"
                          Anchor="{Binding Anchor, Mode=OneWayToSource}" />
    </DataTemplate>
</nodify:Node.InputConnectorTemplate>

<nodify:Node.OutputConnectorTemplate>
    <DataTemplate DataType="{x:Type local:ConnectorViewModel}">
        <nodify:NodeOutput Header="{Binding Title}"
                           IsConnected="True"
                           Anchor="{Binding Anchor, Mode=OneWayToSource}"  />
    </DataTemplate>
</nodify:Node.OutputConnectorTemplate>

并将连接绑定到视图,让它们在 ConnectionTemplate 中使用我们 ConnectorViewModelAnchor。有关更多自定义,请参阅连接概述

<nodify:NodifyEditor ItemsSource="{Binding Nodes}"
                     Connections="{Binding Connections}">
    ...
    <nodify:NodifyEditor.ConnectionTemplate>
        <DataTemplate DataType="{x:Type local:ConnectionViewModel}">
            <nodify:LineConnection Source="{Binding Source.Anchor}"
                                   Target="{Binding Target.Anchor}" />
        </DataTemplate>
    </nodify:NodifyEditor.ConnectionTemplate>
    ...

如果你现在启动应用程序,你会看到有一个连接,并且如果你拖动节点,连接会跟随它们移动。

现在让我们在 ConnectorViewModel 中添加 IsConnected 属性,以便在实际连接时设置它。并更新 ConnectionViewModel 以便在构造时自动连接它们。

public class ConnectorViewModel : INotifyPropertyChanged
{
    private Point _anchor;
    public Point Anchor
    {
        set
        {
            _anchor = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Anchor)));
        }
        get => _anchor;
    }

    private bool _isConnected;
    public bool IsConnected
    {
        set
        {
            _isConnected = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsConnected)));
        }
        get => _isConnected;
    }

    public string Title { get; set; }

    public event PropertyChangedEventHandler PropertyChanged;
}

public class ConnectionViewModel
{
    public ConnectionViewModel(ConnectorViewModel source, ConnectorViewModel target)
    {
        Source = source;
        Target = target;

        Source.IsConnected = true;
        Target.IsConnected = true;
    }

    public ConnectorViewModel Source { get; }
    public ConnectorViewModel Target { get; }
}

并且不要忘记在连接器模板中绑定它。

IsConnected="{Binding IsConnected}"

将预备连接转换为实际连接。

预备连接从一个 Source 开始,当放置到一个 Target 上时将完成。源始终是一个连接器,目标可以是一个连接器、一个项目容器null。我们现在只关心其他连接器。当连接开始时,执行 StartedCommand,该命令接收 Source 作为参数。当连接完成时,执行 CompletedCommand,该命令接收 Target 作为参数。

让我们实现预备连接的视图模型,并将其添加到 EditorViewModel 中。

public class PendingConnectionViewModel
{
    private readonly EditorViewModel _editor;
    private ConnectorViewModel _source;

    public PendingConnectionViewModel(EditorViewModel editor)
    {
        _editor = editor;
        StartCommand = new DelegateCommand<ConnectorViewModel>(source => _source = source);
        FinishCommand = new DelegateCommand<ConnectorViewModel>(target =>
        {
            if (target != null)
                _editor.Connect(_source, target);
        });
    }

    public ICommand StartCommand { get; }
    public ICommand FinishCommand { get; }
}

public class EditorViewModel
{
    public PendingConnectionViewModel PendingConnection { get; }

    ...

    public EditorViewModel()
    {
        PendingConnection = new PendingConnectionViewModel(this);
        ...
    }

    ...

    public void Connect(ConnectorViewModel source, ConnectorViewModel target)
    {
        Connections.Add(new ConnectionViewModel(source, target));
    }
}

并将其绑定到视图上

<nodify:NodifyEditor PendingConnection="{Binding PendingConnection}">
...
    <nodify:NodifyEditor.PendingConnectionTemplate>
        <DataTemplate DataType="{x:Type local:PendingConnectionViewModel}">
            <nodify:PendingConnection StartedCommand="{Binding StartCommand}"
                                      CompletedCommand="{Binding FinishCommand}"
                                      AllowOnlyConnectors="True" />
        </DataTemplate>
    </nodify:NodifyEditor.PendingConnectionTemplate>
...
</nodify:NodifyEditor>

这就是创建连接的全部内容。现在你应该可以在连接器之间创建连接了。

移除连接

要删除连接,只需监听来自连接器本身或编辑器的断开连接事件,并删除具有连接器作为源或目标的连接。为了简单起见,我们将为 NodifyEditor 实现 DisconnectConnectorCommand。首先让我们将其添加到 EditorViewModel

public class EditorViewModel
{
    public ICommand DisconnectConnectorCommand { get; }

    ...

    public EditorViewModel()
    {
        DisconnectConnectorCommand = new DelegateCommand<ConnectorViewModel>(connector =>
        {
            var connection = Connections.First(x => x.Source == connector || x.Target == connector);
            connection.Source.IsConnected = false;  // This is not correct if there are multiple connections to the same connector
            connection.Target.IsConnected = false;
            Connections.Remove(connection);
        });

        ...
    }
}

现在我们将此命令绑定到编辑器试图上。

<nodify:NodifyEditor ItemsSource="{Binding Nodes}"
                     Connections="{Binding Connections}"
                     PendingConnection="{Binding PendingConnection}"
                     DisconnectConnectorCommand="{Binding DisconnectConnectorCommand}">
  ...
  
</nodify:NodifyEditor>

控制节点位置

如你所见,节点总是在屏幕的左上角。这是因为它们在图中的位置是 (0, 0)。让我们来改变这一点!

NodeViewModel 中添加一个 Location 属性,类型为 System.Windows.Point,并触发 PropertyChanged 事件。

public class NodeViewModel : INotifyPropertyChanged
{
    private Point _location;
    public Point Location
    {
        set
        {
            _location = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Location)));
        }
        get => _location;
    }

    public event PropertyChangedEventHandler PropertyChanged;

    ...
}

并将其绑定到视图

<nodify:NodifyEditor ItemsSource="{Binding Nodes}"
                     Connections="{Binding Connections}"
                     PendingConnection="{Binding PendingConnection}">

    <nodify:NodifyEditor.ItemContainerStyle>
        <Style TargetType="{x:Type nodify:ItemContainer}">
            <Setter Property="Location" 
                    Value="{Binding Location}" />
        </Style>
    </nodify:NodifyEditor.ItemContainerStyle>

    ...

</nodify:NodifyEditor>

注意:我使用了 ItemContainerStyle 来绑定节点的位置。请查看项目容器概述获取更多信息。

现在你可以在构造节点时设置它们的位置。

绘制轴网

绘制简单的网格只需创建一个网格画笔,同时将编辑器的变换持续应用于它,并将该画笔用作编辑器的 Background

因为我们绘制的网格是由线条组成的,而不是填充的,所以编辑器的 Background 将具有一些透明度,这意味着我们会看到下面控件的背景颜色。为了解决这个问题,将编辑器包装在一个 Grid 中,并设置其 Background,或者设置 WindowBackground

使用 ViewportTransform 依赖属性使网格随视图移动。

注意:示例使用了在 App.xaml 中选择的主题提供的静态资源。

<Window x:Class="MyProject.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        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:nodify="https://miroiu.github.io/nodify"
        mc:Ignorable="d">

    <Window.Resources>
        <GeometryDrawing x:Key="SmallGridGeometry"
                        Geometry="M0,0 L0,1 0.03,1 0.03,0.03 1,0.03 1,0 Z"
                        Brush="{StaticResource NodifyEditor.SelectionRectangleBackgroundBrush}" />

        <GeometryDrawing x:Key="LargeGridGeometry"
                        Geometry="M0,0 L0,1 0.015,1 0.015,0.015 1,0.015 1,0 Z"
                        Brush="{StaticResource NodifyEditor.SelectionRectangleBackgroundBrush}" />

        <DrawingBrush x:Key="SmallGridLinesDrawingBrush"
                    TileMode="Tile"
                    ViewportUnits="Absolute"
                    Viewport="0 0 20 20"
                    Transform="{Binding ViewportTransform, ElementName=Editor}"
                    Drawing="{StaticResource SmallGridGeometry}" />

        <DrawingBrush x:Key="LargeGridLinesDrawingBrush"
                    TileMode="Tile"
                    ViewportUnits="Absolute"
                    Opacity="0.5"
                    Viewport="0 0 100 100"
                    Transform="{Binding ViewportTransform, ElementName=Editor}"
                    Drawing="{StaticResource LargeGridGeometry}" />
    </Window.Resources>

    <Grid Background="{StaticResource NodifyEditor.BackgroundBrush}">
        <nodify:NodifyEditor x:Name="Editor" Background="{StaticResource SmallGridLinesDrawingBrush}" />

        <Grid Background="{StaticResource LargeGridLinesDrawingBrush}"
              Panel.ZIndex="-2" />
    </Grid>
</Window>

提示:右键单击并拖动屏幕以移动视图,使用鼠标滚轮放大和缩小。