From wpf-dev-pack
Guides Nodify library integration for node-based editors in WPF MVVM apps. Use for visual scripting, workflow editors, state machines, node graphs with NodifyEditor, Nodes, Connectors, gestures, multi-selection, and keyboard handling.
npx claudepluginhub christian289/dotnet-with-claudecode --plugin wpf-dev-packThis skill uses the workspace's default tool permissions.
Nodify 7.x 기반 노드 에디터 구현 가이드.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Nodify 7.x 기반 노드 에디터 구현 가이드.
<PackageReference Include="Nodify" Version="7.2.*" />
xmlns:nodify="https://miroiu.github.io/nodify"
| Control | 용도 |
|---|---|
NodifyEditor | 메인 캔버스 (줌, 팬, 선택) |
Node | 표준 노드 (Header, Input, Output) |
GroupingNode | 노드 그룹화 |
KnotNode | 연결선 경유점 |
StateNode | 상태 머신용 노드 |
NodeInput / NodeOutput | 입출력 커넥터 |
Connector | 연결 포인트 |
Connection | 베지어 커브 연결선 |
LineConnection | 직선 연결 |
CircuitConnection | 직각 연결 (회로 스타일) |
StepConnection | 계단형 연결 |
PendingConnection | 드래그 중 연결 미리보기 |
Minimap | 전체 뷰 미니맵 |
namespace MyApp.ViewModels;
public sealed partial class ConnectorViewModel : ObservableObject
{
[ObservableProperty] private Point _anchor;
[ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _title = string.Empty;
}
namespace MyApp.ViewModels;
public sealed partial class NodeViewModel : ObservableObject
{
[ObservableProperty] private string _title = string.Empty;
[ObservableProperty] private Point _location;
public ObservableCollection<ConnectorViewModel> Input { get; } = [];
public ObservableCollection<ConnectorViewModel> Output { get; } = [];
}
namespace MyApp.ViewModels;
public sealed class ConnectionViewModel(ConnectorViewModel source, ConnectorViewModel target)
{
public ConnectorViewModel Source { get; } = source;
public ConnectorViewModel Target { get; } = target;
public void SetConnected(bool value)
{
Source.IsConnected = value;
Target.IsConnected = value;
}
}
namespace MyApp.ViewModels;
public sealed class PendingConnectionViewModel
{
private readonly EditorViewModel _editor;
private ConnectorViewModel? _source;
public PendingConnectionViewModel(EditorViewModel editor)
{
_editor = editor;
StartCommand = new RelayCommand<ConnectorViewModel>(source => _source = source);
FinishCommand = new RelayCommand<ConnectorViewModel>(target =>
{
if (target is not null && _source is not null)
_editor.Connect(_source, target);
});
}
public IRelayCommand<ConnectorViewModel> StartCommand { get; }
public IRelayCommand<ConnectorViewModel> FinishCommand { get; }
}
namespace MyApp.ViewModels;
public sealed partial class EditorViewModel : ObservableObject
{
public ObservableCollection<NodeViewModel> Nodes { get; } = [];
public ObservableCollection<ConnectionViewModel> Connections { get; } = [];
public PendingConnectionViewModel PendingConnection { get; }
public EditorViewModel()
{
PendingConnection = new PendingConnectionViewModel(this);
DisconnectConnectorCommand = new RelayCommand<ConnectorViewModel>(Disconnect);
}
public IRelayCommand<ConnectorViewModel> DisconnectConnectorCommand { get; }
public void Connect(ConnectorViewModel source, ConnectorViewModel target)
{
var connection = new ConnectionViewModel(source, target);
connection.SetConnected(true);
Connections.Add(connection);
}
private void Disconnect(ConnectorViewModel? connector)
{
if (connector is null) return;
var connection = Connections.FirstOrDefault(
c => c.Source == connector || c.Target == connector);
if (connection is null) return;
connection.SetConnected(false);
Connections.Remove(connection);
}
}
<nodify:NodifyEditor ItemsSource="{Binding Nodes}"
Connections="{Binding Connections}"
PendingConnection="{Binding PendingConnection}"
DisconnectConnectorCommand="{Binding DisconnectConnectorCommand}">
<!-- 노드 위치 바인딩 -->
<!-- Node position binding -->
<nodify:NodifyEditor.ItemContainerStyle>
<Style TargetType="{x:Type nodify:ItemContainer}">
<Setter Property="Location" Value="{Binding Location}" />
</Style>
</nodify:NodifyEditor.ItemContainerStyle>
<!-- 노드 템플릿 -->
<!-- Node template -->
<nodify:NodifyEditor.ItemTemplate>
<DataTemplate DataType="{x:Type local:NodeViewModel}">
<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}"
IsConnected="{Binding IsConnected}"
Anchor="{Binding Anchor, Mode=OneWayToSource}" />
</DataTemplate>
</nodify:Node.InputConnectorTemplate>
<nodify:Node.OutputConnectorTemplate>
<DataTemplate DataType="{x:Type local:ConnectorViewModel}">
<nodify:NodeOutput Header="{Binding Title}"
IsConnected="{Binding IsConnected}"
Anchor="{Binding Anchor, Mode=OneWayToSource}" />
</DataTemplate>
</nodify:Node.OutputConnectorTemplate>
</nodify:Node>
</DataTemplate>
</nodify:NodifyEditor.ItemTemplate>
<!-- 연결선 템플릿 -->
<!-- Connection template -->
<nodify:NodifyEditor.ConnectionTemplate>
<DataTemplate DataType="{x:Type local:ConnectionViewModel}">
<nodify:LineConnection Source="{Binding Source.Anchor}"
Target="{Binding Target.Anchor}" />
</DataTemplate>
</nodify:NodifyEditor.ConnectionTemplate>
</nodify:NodifyEditor>
| Type | XAML | 설명 |
|---|---|---|
| Bezier | <nodify:Connection> | 기본 베지어 커브 |
| Line | <nodify:LineConnection> | 직선 |
| Circuit | <nodify:CircuitConnection> | 직각 (회로 스타일) |
| Step | <nodify:StepConnection> | 계단형 |
<!-- 연결 스타일만 교체하면 됨 -->
<!-- Just swap the connection style -->
<nodify:CircuitConnection Source="{Binding Source.Anchor}"
Target="{Binding Target.Anchor}" />
EditorGestures.Mappings으로 마우스/키보드 제스처를 커스터마이징:
// App 초기화 시 (App.xaml.cs 등)
using Nodify.Interactivity;
// 줌: Ctrl + 마우스 휠
EditorGestures.Mappings.Editor.ZoomModifierKey = ModifierKeys.Control;
// 패닝: Ctrl + 좌클릭 (기본값: 우클릭)
EditorGestures.Mappings.Editor.Pan.Value = new MouseGesture(MouseAction.LeftClick, ModifierKeys.Control);
// 선택 비활성화 (드래그 사각형 선택 끄기)
EditorGestures.Mappings.Editor.Selection.Apply(EditorGestures.SelectionGestures.None);
// 아이템 컨테이너 선택 제스처 (기본값)
// Replace: LeftClick → 클릭한 노드만 선택
// Append: Shift+LeftClick → 기존 선택에 추가
// Invert: Ctrl+LeftClick → 선택 반전
// Remove: Alt+LeftClick → 선택에서 제거
// 개별 해제 가능:
EditorGestures.Mappings.ItemContainer.Selection.Append.Unbind();
EditorGestures.Mappings.ItemContainer.CancelAction.Unbind();
주의: Pan 제스처를 Ctrl+LeftClick으로 설정하면 기본 Invert 선택 제스처와 충돌. 필요 시 Invert를 Unbind하거나 다른 조합으로 변경.
<nodify:NodifyEditor CanSelectMultipleItems="True"
SelectedItem="{Binding SelectedNode}"
... />
NodifyEditor.SelectedItems는 별도 초기화 없이 null을 반환. 직접 접근하면 NullReferenceException 또는 ArgumentNullException 발생:
// BAD — NullReferenceException
var count = editor.SelectedItems.Count;
// BAD — ArgumentNullException (LINQ)
var nodes = editor.SelectedItems.OfType<INodeItem>().ToList();
private List<INodeItem> GetSelectedNodes(NodifyEditor editor)
{
var selectedNodes = new List<INodeItem>();
for (int i = 0; i < editor.Items.Count; i++)
{
if (editor.Items[i] is not INodeItem node)
{
continue;
}
var container = editor.ItemContainerGenerator.ContainerFromIndex(i) as ItemContainer;
if (container is { IsSelected: true })
{
selectedNodes.Add(node);
}
}
// 다중 선택이 없으면 SelectedItem 폴백
if (selectedNodes.Count <= 0 && editor.SelectedItem is INodeItem selectedNode)
{
selectedNodes.Add(selectedNode);
}
return selectedNodes;
}
NodifyEditor는 OnKeyDown을 InputProcessor에 위임하여 키보드 네비게이션 등에 사용. Delete 키를 커스텀 처리하려면 NodifyEditor보다 먼저 이벤트를 가로채야 함.
문제: NodifyEditor는 기본적으로 키보드 포커스를 받지 않으므로, Editor에 직접 PreviewKeyDown을 걸어도 동작하지 않음.
해결: Behavior에서 부모 Window의 PreviewKeyDown을 구독:
public class EditorDeleteBehavior : Behavior<NodifyEditor>
{
public static readonly DependencyProperty DeleteCommandProperty =
DependencyProperty.Register(nameof(DeleteCommand), typeof(ICommand),
typeof(EditorDeleteBehavior));
public ICommand? DeleteCommand { get; set; }
private Window? _parentWindow;
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Loaded += OnLoaded;
AssociatedObject.Unloaded += OnUnloaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
_parentWindow = Window.GetWindow(AssociatedObject);
if (_parentWindow is not null)
{
_parentWindow.PreviewKeyDown += OnWindowPreviewKeyDown;
}
}
private void OnWindowPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Delete) return;
// 텍스트 입력 중이면 무시
if (Keyboard.FocusedElement is TextBox or TextBoxBase or PasswordBox) return;
// Editor 영역에서만 처리
if (!AssociatedObject.IsMouseOver && !AssociatedObject.IsKeyboardFocusWithin) return;
var selectedNodes = GetSelectedNodes(AssociatedObject);
if (selectedNodes.Count > 0 && DeleteCommand?.CanExecute(selectedNodes) == true)
{
DeleteCommand.Execute(selectedNodes);
e.Handled = true;
}
}
// ... OnUnloaded에서 구독 해제
}
스코프 가드 조건:
IsMouseOver — 마우스가 Editor 위에 있으면 처리 (키보드 포커스 없어도)IsKeyboardFocusWithin — Editor 내부에 포커스가 있으면 처리TextBox 체크 — 검색창 등에서 Delete 키가 텍스트 삭제로 동작하도록| 실수 | 올바른 방법 |
|---|---|
Anchor 바인딩 누락 | Anchor="{Binding Anchor, Mode=OneWayToSource}" 필수 |
Location 바인딩 누락 | ItemContainerStyle에서 Location 바인딩 |
ViewModel에 Point 사용 | System.Windows.Point는 WPF 타입 — ViewModel 분리 시 주의 |
PendingConnection 미설정 | 드래그로 연결 생성이 동작하지 않음 |
IsConnected 미갱신 | 연결 추가/제거 시 양쪽 커넥터의 IsConnected 반드시 갱신 |
⚠️
System.Windows.Point는WindowsBase.dll소속. ViewModel 프로젝트를 순수 BCL로 유지하려면double X, Y프로퍼티로 분리하고 Converter에서 변환.
NodifyEditor에 ItemsSource, Connections, PendingConnection 3개 필수 바인딩ItemContainerStyle로 노드 Location 바인딩Anchor는 Mode=OneWayToSource (Nodify가 좌표를 계산하여 ViewModel에 전달)IsConnected 양쪽 갱신ConnectionTemplate에서 컨트롤만 교체Point 대신 double X, Y 사용