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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/wpf-dev-pack:integrating-nodifysonnetThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Nodify 7.x 기반 노드 에디터 구현 가이드.
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 사용npx claudepluginhub christian289/dotnet-with-claudecode --plugin wpf-dev-packCreates custom React Flow nodes, edges, and handles with React components, TypeScript types, Tailwind styling, and registration. For building interactive node-based editors and diagrams.
Creates custom Svelte Flow nodes, edges, and handles including node components, registration, resizable nodes, toolbars, and Tailwind styling.
Implements advanced React Flow patterns: sub-flows/groups, custom connection lines, programmatic dagre layouts, drag-and-drop, undo/redo, and store selector optimization.