diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82b0c67f..7eb77274 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,27 @@
> - Breaking Changes:
> - Added ProcessHandledEvents to IInputHandler and removed it from InputProcessor
+> - Renamed EditorGestures.Editor.ResetViewportLocation to EditorGestures.Editor.ResetViewport
> - Features:
+> - Introduced a new BringIntoView method overload in NodifyEditor that accepts an offset from the viewport edges
+> - Added BringIntoViewEdgeOffset to NodifyEditor to control the viewport edge offset when bringing the focused element into view
+> - Added ResetViewport to NodifyEditor to reset the viewport's location and zoom
+> - Improved tab and directional navigation, ensuring that focused elements are automatically brought into view
+> - Added keyboard navigation layers for nodes, connections and decorators; restricting keyboard navigation to the active layer
+> - Added ActiveNavigationLayer, ActivateNextNavigationLayer, ActivatePreviousNavigationLayer, RegisterNavigationLayer, RemoveNavigationLayer and ActivateNavigationLayer to NodifyEditor for keyboard layers management
+> - Added KeyboardNavigationLayer property to NodifyEditor that allows navigating through the ItemContainers
+> - Added AutoRegisterConnectionsLayer, AutoRegisterDecoratorsLayer, AutoFocusFirstElement, AutoPanOnNodeFocus, PanViewportOnKeyboardDrag and MinimumNavigationStepSize to NodifyEditor
+> - Added EditorGestures.Editor.Keyboard for keyboard navigation gestures
+> - Added FindNextFocusTarget, OnElementFocused and OnKeyboardNavigationLayerActivated virtual methods to NodifyEditor
+> - Added new gestures for keyboard navigation available in EditorGestures.Editor.Keyboard
+> - Added ToggleContentSelection to GroupingNode and its corresponding gesture to toggle the selection of nodes inside the group
+> - Added ZoomIn, ZoomOut and ResetViewport methods to the Minimap control
+> - Added ZoomIn, ZoomOut, ResetViewport and Pan gestures to EditorGestures.Minimap
+> - Added NavigationStepSize static property to Minimap
+> - Added Unbind to all gestures inside EditorGestures
+> - Added the KeyComboGesture that requires a trigger key to be held down before pressing a combo key
+> - Added FocusVisualPen and FocusVisualPadding dependency properties to BaseConnection
+> - Added default focus visuals for base editor controls that can be included by referencing the FocusVisual.xaml file
> - Bugfixes:
#### **Version 7.0.4**
diff --git a/Examples/Nodify.Calculator/App.xaml b/Examples/Nodify.Calculator/App.xaml
index 2c6b2108..083121d7 100644
--- a/Examples/Nodify.Calculator/App.xaml
+++ b/Examples/Nodify.Calculator/App.xaml
@@ -1,13 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/Nodify.Calculator/EditorView.xaml b/Examples/Nodify.Calculator/EditorView.xaml
index 24f923a0..41efa541 100644
--- a/Examples/Nodify.Calculator/EditorView.xaml
+++ b/Examples/Nodify.Calculator/EditorView.xaml
@@ -142,7 +142,7 @@
x:Name="Editor">
+
+
+
+ DodgerBlue
+
diff --git a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml
index eb514c82..76c97759 100644
--- a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml
+++ b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml
@@ -175,6 +175,12 @@
Value="{Binding SelectableConnections, Source={x:Static local:EditorSettings.Instance}}" />
+
+
+
@@ -531,7 +537,8 @@
-
+
@@ -548,7 +555,8 @@
-
+
diff --git a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs
index 6867bfe8..e25e9ae9 100644
--- a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs
+++ b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs
@@ -11,6 +11,8 @@ public partial class NodifyEditorView : UserControl
public NodifyEditorView()
{
InitializeComponent();
+
+ EditorInstance.ActiveNavigationLayerChanged += DisplayActiveNavigationLayer;
}
static NodifyEditorView()
@@ -23,5 +25,27 @@ private void Minimap_Zoom(object sender, ZoomEventArgs e)
{
EditorInstance.ZoomAtPosition(e.Zoom, e.Location);
}
+
+ private void DisplayActiveNavigationLayer(KeyboardNavigationLayerId layerId)
+ {
+ var editorVm = (NodifyEditorViewModel)EditorInstance.DataContext;
+
+ if (layerId == KeyboardNavigationLayerId.Nodes)
+ {
+ editorVm.KeyboardNavigationLayer = nameof(KeyboardNavigationLayerId.Nodes);
+ }
+ else if (layerId == KeyboardNavigationLayerId.Connections)
+ {
+ editorVm.KeyboardNavigationLayer = nameof(KeyboardNavigationLayerId.Connections);
+ }
+ else if (layerId == KeyboardNavigationLayerId.Decorators)
+ {
+ editorVm.KeyboardNavigationLayer = nameof(KeyboardNavigationLayerId.Decorators);
+ }
+ else
+ {
+ editorVm.KeyboardNavigationLayer = "Custom";
+ }
+ }
}
}
diff --git a/Examples/Nodify.Playground/Editor/NodifyEditorViewModel.cs b/Examples/Nodify.Playground/Editor/NodifyEditorViewModel.cs
index d38a5af9..dd44ab28 100644
--- a/Examples/Nodify.Playground/Editor/NodifyEditorViewModel.cs
+++ b/Examples/Nodify.Playground/Editor/NodifyEditorViewModel.cs
@@ -102,6 +102,13 @@ public NodeViewModel? SelectedNode
public GraphSchema Schema { get; }
+ private string? _keyboardNavigationLayer;
+ public string? KeyboardNavigationLayer
+ {
+ get => _keyboardNavigationLayer;
+ set => SetProperty(ref _keyboardNavigationLayer, value);
+ }
+
public ICommand DeleteSelectionCommand { get; }
public ICommand DisconnectConnectorCommand { get; }
public ICommand CreateConnectionCommand { get; }
diff --git a/Examples/Nodify.Playground/EditorSettings.cs b/Examples/Nodify.Playground/EditorSettings.cs
index aac5d31b..7bd89785 100644
--- a/Examples/Nodify.Playground/EditorSettings.cs
+++ b/Examples/Nodify.Playground/EditorSettings.cs
@@ -193,6 +193,18 @@ private EditorSettings()
() => Instance.ConnectionTargetOffset,
val => Instance.ConnectionTargetOffset = val,
"Connection target offset: "),
+ new ProxySettingViewModel(
+ () => Instance.ConnectionStrokeThickness,
+ val => Instance.ConnectionStrokeThickness = val,
+ "Connection stroke thickness: "),
+ new ProxySettingViewModel(
+ () => Instance.ConnectionOutlineThickness,
+ val => Instance.ConnectionOutlineThickness = val,
+ "Connection outline thickness: "),
+ new ProxySettingViewModel(
+ () => Instance.ConnectionFocusVisualPadding,
+ val => Instance.ConnectionFocusVisualPadding = val,
+ "Connection focus visual padding: "),
new ProxySettingViewModel(
() => Instance.DisplayConnectionsOnTop,
val => Instance.DisplayConnectionsOnTop = val,
@@ -614,6 +626,27 @@ public PointEditor ConnectionTargetOffset
set => SetProperty(ref _connectionTargetOffset, value);
}
+ private double _connectionStrokeThickness = 3;
+ public double ConnectionStrokeThickness
+ {
+ get => _connectionStrokeThickness;
+ set => SetProperty(ref _connectionStrokeThickness, value);
+ }
+
+ private double _connectionOutlineThickness = 5;
+ public double ConnectionOutlineThickness
+ {
+ get => _connectionOutlineThickness;
+ set => SetProperty(ref _connectionOutlineThickness, value);
+ }
+
+ private double _connectionFocusVisualPadding = 1;
+ public double ConnectionFocusVisualPadding
+ {
+ get => _connectionFocusVisualPadding;
+ set => SetProperty(ref _connectionFocusVisualPadding, value);
+ }
+
private uint _directionalArrowsCount = 3;
public uint DirectionalArrowsCount
{
diff --git a/Examples/Nodify.Playground/MainWindow.xaml b/Examples/Nodify.Playground/MainWindow.xaml
index 67812655..9c74ff0b 100644
--- a/Examples/Nodify.Playground/MainWindow.xaml
+++ b/Examples/Nodify.Playground/MainWindow.xaml
@@ -14,6 +14,7 @@
+
@@ -219,6 +220,22 @@
+
+
+
+
+
+
+
+
-
+
-
-
+
+
-
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
+
+
-
+
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/Nodify.Shapes/App.xaml.cs b/Examples/Nodify.Shapes/App.xaml.cs
index bff9a081..5b0e8d6b 100644
--- a/Examples/Nodify.Shapes/App.xaml.cs
+++ b/Examples/Nodify.Shapes/App.xaml.cs
@@ -15,6 +15,7 @@ public App()
NodifyEditor.EnableCuttingLinePreview = true;
EditorGestures.Mappings.Connection.Disconnect.Value = new AnyGesture(new Interactivity.MouseGesture(MouseAction.LeftClick, ModifierKeys.Alt), new Interactivity.MouseGesture(MouseAction.RightClick));
+ EditorGestures.Mappings.Editor.Pan.Value = new AnyGesture(EditorGestures.Mappings.Editor.Pan.Value, new Interactivity.MouseGesture(MouseAction.LeftClick, Key.Space));
}
}
}
diff --git a/Examples/Nodify.Shapes/Canvas/CanvasView.xaml b/Examples/Nodify.Shapes/Canvas/CanvasView.xaml
index 7c5186bd..60b0b75a 100644
--- a/Examples/Nodify.Shapes/Canvas/CanvasView.xaml
+++ b/Examples/Nodify.Shapes/Canvas/CanvasView.xaml
@@ -70,6 +70,7 @@
+ GridCellSize="5"
+ Focusable="False">
@@ -378,6 +383,7 @@
MinWidth="250"
MinHeight="150"
BorderBrush="{x:Null}"
+ Focusable="False"
Margin="20">
-
+
+ SelectedValue="{Binding CanvasToolbar.SelectedTool}"
+ KeyboardNavigation.ControlTabNavigation="None">
-
+
-
+
-
+
diff --git a/Examples/Nodify.Shapes/MainWindow.xaml.cs b/Examples/Nodify.Shapes/MainWindow.xaml.cs
index 43014701..c126ba7b 100644
--- a/Examples/Nodify.Shapes/MainWindow.xaml.cs
+++ b/Examples/Nodify.Shapes/MainWindow.xaml.cs
@@ -1,4 +1,5 @@
using System.Windows;
+using System.Windows.Input;
namespace Nodify.Shapes
{
@@ -10,6 +11,16 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
+
+ EventManager.RegisterClassHandler(
+ typeof(UIElement),
+ Keyboard.PreviewGotKeyboardFocusEvent,
+ (KeyboardFocusChangedEventHandler)OnPreviewGotKeyboardFocus);
+ }
+
+ private void OnPreviewGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
+ {
+ Title = e.NewFocus.ToString();
}
}
}
\ No newline at end of file
diff --git a/Examples/Nodify.Shared/Controls/EditableTextBlock.cs b/Examples/Nodify.Shared/Controls/EditableTextBlock.cs
index 4a59dadc..8998152b 100644
--- a/Examples/Nodify.Shared/Controls/EditableTextBlock.cs
+++ b/Examples/Nodify.Shared/Controls/EditableTextBlock.cs
@@ -99,6 +99,13 @@ public override void OnApplyTemplate()
{
base.OnApplyTemplate();
+ if (TextBox != null)
+ {
+ TextBox.LostFocus -= OnLostFocus;
+ TextBox.LostKeyboardFocus -= OnLostFocus;
+ TextBox.IsVisibleChanged -= OnTextBoxVisiblityChanged;
+ }
+
TextBox = GetTemplateChild(ElementTextBox) as TextBox;
if (TextBox != null)
@@ -163,7 +170,7 @@ protected override void OnKeyDown(KeyEventArgs e)
IsEditing = false;
}
- if(e.Key == Key.Enter && IsFocused && !IsEditing)
+ if (e.Key == Key.Enter && IsFocused && !IsEditing)
{
IsEditing = true;
}
diff --git a/Examples/Nodify.Shared/ThemeManager.cs b/Examples/Nodify.Shared/ThemeManager.cs
index fd1cbcc6..ab84be50 100644
--- a/Examples/Nodify.Shared/ThemeManager.cs
+++ b/Examples/Nodify.Shared/ThemeManager.cs
@@ -115,7 +115,7 @@ public static void SetTheme(string themeName)
{
foreach (var res in resources)
{
- Application.Current.Resources.MergedDictionaries.Add(res);
+ Application.Current.Resources.MergedDictionaries.Insert(0, res);
}
// Unload current theme
diff --git a/Examples/Nodify.Shared/Themes/Controls.xaml b/Examples/Nodify.Shared/Themes/Controls.xaml
index e6a15285..ffe5f4a6 100644
--- a/Examples/Nodify.Shared/Themes/Controls.xaml
+++ b/Examples/Nodify.Shared/Themes/Controls.xaml
@@ -15,6 +15,8 @@
Value="1" />
+
@@ -91,6 +93,8 @@
Value="4 2" />
+
+
@@ -156,6 +162,8 @@
Value="{DynamicResource BorderBrush}" />
+
@@ -242,6 +250,8 @@
Value="{DynamicResource BackgroundBrush}" />
+
@@ -433,6 +443,8 @@
Value="{DynamicResource HighlightedBackgroundBrush}" />
+
@@ -484,6 +496,8 @@
Value="{DynamicResource HighlightedBackgroundBrush}" />
+
@@ -538,6 +552,8 @@
Value="Hand" />
+
@@ -769,6 +785,7 @@
Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}"
+ FocusVisualStyle="{StaticResource {x:Static SystemParameters.FocusVisualStyleKey}}"
Cursor="Hand"
x:Name="PART_Header">
diff --git a/Examples/Nodify.StateMachine/MainWindow.xaml b/Examples/Nodify.StateMachine/MainWindow.xaml
index c707a3ff..803983c0 100644
--- a/Examples/Nodify.StateMachine/MainWindow.xaml
+++ b/Examples/Nodify.StateMachine/MainWindow.xaml
@@ -380,7 +380,8 @@
Grid.Row="1" />
+ Grid.Row="2"
+ Focusable="False">
-
+
diff --git a/Examples/Nodify.StateMachine/MainWindow.xaml.cs b/Examples/Nodify.StateMachine/MainWindow.xaml.cs
index 25ce241a..6fa99fd5 100644
--- a/Examples/Nodify.StateMachine/MainWindow.xaml.cs
+++ b/Examples/Nodify.StateMachine/MainWindow.xaml.cs
@@ -17,6 +17,16 @@ public MainWindow()
EditorGestures.Mappings.Connection.Disconnect.Unbind();
EditorGestures.Mappings.Editor.ZoomModifierKey = ModifierKeys.Control;
EditorGestures.Mappings.Editor.PanWithMouseWheel = true;
+
+ EventManager.RegisterClassHandler(
+ typeof(UIElement),
+ Keyboard.PreviewGotKeyboardFocusEvent,
+ (KeyboardFocusChangedEventHandler)OnPreviewGotKeyboardFocus);
+ }
+
+ private void OnPreviewGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
+ {
+ Title = e.NewFocus.ToString();
}
private void ScrollViewer_PreviewKeyDown(object sender, KeyEventArgs e)
diff --git a/Nodify/Connections/BaseConnection.cs b/Nodify/Connections/BaseConnection.cs
index 792318ee..109a4df5 100644
--- a/Nodify/Connections/BaseConnection.cs
+++ b/Nodify/Connections/BaseConnection.cs
@@ -109,7 +109,7 @@ public enum ArrowHeadShape
///
/// Represents the base class for shapes that are drawn from a point to a point.
///
- public abstract class BaseConnection : Shape
+ public abstract class BaseConnection : Shape, IKeyboardFocusTarget
{
#region Dependency Properties
@@ -134,6 +134,8 @@ public abstract class BaseConnection : Shape
public static readonly DependencyProperty DisconnectCommandProperty = Connector.DisconnectCommandProperty.AddOwner(typeof(BaseConnection));
public static readonly DependencyProperty OutlineThicknessProperty = DependencyProperty.Register(nameof(OutlineThickness), typeof(double), typeof(BaseConnection), new FrameworkPropertyMetadata(BoxValue.Double5, FrameworkPropertyMetadataOptions.AffectsRender, new PropertyChangedCallback(OnOutlinePenChanged)));
public static readonly DependencyProperty OutlineBrushProperty = DependencyProperty.Register(nameof(OutlineBrush), typeof(Brush), typeof(BaseConnection), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, new PropertyChangedCallback(OnOutlinePenChanged)));
+ public static readonly DependencyProperty FocusVisualPenProperty = DependencyProperty.Register(nameof(FocusVisualPen), typeof(Pen), typeof(BaseConnection), new FrameworkPropertyMetadata(DefaultFocusVisualPen, FrameworkPropertyMetadataOptions.AffectsRender));
+ public static readonly DependencyProperty FocusVisualPaddingProperty = DependencyProperty.Register(nameof(FocusVisualPadding), typeof(double), typeof(BaseConnection), new FrameworkPropertyMetadata(BoxValue.Double1, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty ForegroundProperty = TextBlock.ForegroundProperty.AddOwner(typeof(BaseConnection));
public static readonly DependencyProperty TextProperty = TextBlock.TextProperty.AddOwner(typeof(BaseConnection), new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty FontSizeProperty = TextElement.FontSizeProperty.AddOwner(typeof(BaseConnection));
@@ -385,6 +387,24 @@ public Brush? OutlineBrush
set => SetValue(OutlineBrushProperty, value);
}
+ ///
+ /// The pen used to render the focus visual.
+ ///
+ public Pen? FocusVisualPen
+ {
+ get => (Pen?)GetValue(FocusVisualPenProperty);
+ set => SetValue(FocusVisualPenProperty, value);
+ }
+
+ ///
+ /// The space between the focus visual and the connection geometry.
+ ///
+ public double FocusVisualPadding
+ {
+ get => (double)GetValue(FocusVisualPaddingProperty);
+ set => SetValue(FocusVisualPaddingProperty, value);
+ }
+
///
/// The brush used to render the .
///
@@ -491,7 +511,42 @@ public event ConnectionEventHandler Split
///
protected static readonly Vector ZeroVector = new Vector(0d, 0d);
+ // Use Source for both corners to ensure Top-Left aligns with intended keyboard focus point.
+ Rect IKeyboardFocusTarget.Bounds
+ => new Rect(Direction == ConnectionDirection.Forward ? Target : Source, Direction == ConnectionDirection.Forward ? Target : Source);
+
+ FrameworkElement IKeyboardFocusTarget.Element => this;
+
+ ///
+ /// The key used to retrieve the resource.
+ ///
+ public static ResourceKey FocusVisualPenKey { get; } = new ComponentResourceKey(typeof(BaseConnection), nameof(FocusVisualPen));
+
private Pen? _outlinePen;
+ private static Pen? _defaultFocusVisualPen;
+ private FocusVisualAdorner? _focusVisualAdorner;
+ private AdornerLayer? _adornerLayer;
+
+ private AdornerLayer AdornerLayer => _adornerLayer ??= AdornerLayer.GetAdornerLayer(this);
+
+ private FocusVisualAdorner FocusVisualPenAdorner => _focusVisualAdorner ??= new FocusVisualAdorner(this);
+
+ private static Pen DefaultFocusVisualPen
+ {
+ get
+ {
+ if (_defaultFocusVisualPen is null)
+ {
+ _defaultFocusVisualPen = new Pen(SystemColors.ControlTextBrush, 1)
+ {
+ DashStyle = new DashStyle { Dashes = { 0.5d, 3d } }
+ };
+ _defaultFocusVisualPen.Freeze();
+ }
+
+ return _defaultFocusVisualPen;
+ }
+ }
private readonly StreamGeometry _geometry = new StreamGeometry
{
@@ -921,5 +976,48 @@ protected override void OnRender(DrawingContext drawingContext)
drawingContext.DrawText(text, GetTextPosition(text, Source + sourceOffset, Target + targetOffset));
}
}
+
+ internal void UpdateFocusVisual()
+ {
+ if (AdornerLayer != null)
+ {
+ if (Container is { IsKeyboardFocused: true })
+ {
+ AdornerLayer.Add(FocusVisualPenAdorner);
+ }
+ else
+ {
+ AdornerLayer.Remove(FocusVisualPenAdorner);
+ }
+ }
+ }
+
+ private class FocusVisualAdorner : Adorner
+ {
+ private readonly BaseConnection _baseConnection;
+ private Pen? _cachedPenResource;
+ private Pen? CachedPenResource => _cachedPenResource ??= TryFindResource(FocusVisualPenKey) as Pen;
+
+ public FocusVisualAdorner(BaseConnection baseConnection) : base(baseConnection)
+ {
+ IsHitTestVisible = false;
+ IsEnabled = false;
+ IsClipEnabled = true;
+ _baseConnection = baseConnection;
+ }
+
+ protected override void OnRender(DrawingContext drawingContext)
+ {
+ var drawPen = _baseConnection.FocusVisualPen == DefaultFocusVisualPen
+ ? CachedPenResource
+ : _baseConnection.FocusVisualPen;
+
+ if (drawPen != null)
+ {
+ var widenPen = new Pen(null, _baseConnection.StrokeThickness + drawPen.Thickness + _baseConnection.FocusVisualPadding * 2d);
+ drawingContext.DrawGeometry(null, drawPen, _baseConnection.DefiningGeometry.GetWidenedPathGeometry(widenPen));
+ }
+ }
+ }
}
}
diff --git a/Nodify/Connections/ConnectionContainer.cs b/Nodify/Connections/ConnectionContainer.cs
index 621fb760..89cb2d55 100644
--- a/Nodify/Connections/ConnectionContainer.cs
+++ b/Nodify/Connections/ConnectionContainer.cs
@@ -1,11 +1,13 @@
using Nodify.Interactivity;
+using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
+using System.Windows.Media;
namespace Nodify
{
- internal sealed class ConnectionContainer : ContentPresenter
+ public class ConnectionContainer : ContentPresenter, IKeyboardFocusTarget
{
#region Dependency properties
@@ -66,25 +68,61 @@ public event RoutedEventHandler Unselected
#endregion
- private ConnectionsMultiSelector Selector { get; }
-
private FrameworkElement? _connection;
- private FrameworkElement? Connection => _connection ??= BaseConnection.PrioritizeBaseConnectionForSelection
+ private SelectionType? _selectionType;
+
+ public Rect Bounds => ConnectionFocusTarget.Bounds;
+ ConnectionContainer IKeyboardFocusTarget.Element => this;
+
+ private IKeyboardFocusTarget ConnectionFocusTarget => Connection as IKeyboardFocusTarget
+ ?? throw new NotSupportedException($"Custom connections must implement {nameof(IKeyboardFocusTarget)} for keyboard navigation. Or disable keyboard navigation for the connections layer.");
+
+ public FrameworkElement? Connection => _connection ??= BaseConnection.PrioritizeBaseConnectionForSelection
? this.GetChildOfType() ?? this.GetChildOfType()
: this.GetChildOfType();
- private SelectionType? _selectionType;
+ public ConnectionsMultiSelector Selector { get; }
static ConnectionContainer()
{
FocusableProperty.OverrideMetadata(typeof(ConnectionContainer), new FrameworkPropertyMetadata(BoxValue.True));
+ FocusVisualStyleProperty.OverrideMetadata(typeof(ConnectionContainer), new FrameworkPropertyMetadata(new Style()));
+
+ KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(ConnectionContainer), new FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle));
+ KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(ConnectionContainer), new FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle));
}
- internal ConnectionContainer(ConnectionsMultiSelector selector)
+ public ConnectionContainer(ConnectionsMultiSelector selector)
{
Selector = selector;
}
+ protected override void OnVisualParentChanged(DependencyObject oldParent)
+ {
+ if (VisualTreeHelper.GetParent(this) == null && IsKeyboardFocusWithin)
+ {
+ base.OnVisualParentChanged(oldParent);
+
+ Selector.Editor?.Focus();
+ }
+ else
+ {
+ base.OnVisualParentChanged(oldParent);
+ }
+ }
+
+ protected override void OnIsKeyboardFocusedChanged(DependencyPropertyChangedEventArgs e)
+ {
+ if (Connection is BaseConnection baseConnection)
+ {
+ baseConnection.UpdateFocusVisual();
+ }
+ else
+ {
+ Connection?.InvalidateVisual();
+ }
+ }
+
///
/// Raises the or based on .
/// Called when the value is changed.
@@ -140,7 +178,7 @@ protected override void OnMouseUp(MouseButtonEventArgs e)
/// Modifies the selection state of the current item based on the specified selection type.
///
/// The type of selection to perform.
- private void Select(SelectionType type)
+ public void Select(SelectionType type)
{
switch (type)
{
diff --git a/Nodify/Connections/ConnectionsMultiSelector.cs b/Nodify/Connections/ConnectionsMultiSelector.cs
index 05969bfb..c7da6a57 100644
--- a/Nodify/Connections/ConnectionsMultiSelector.cs
+++ b/Nodify/Connections/ConnectionsMultiSelector.cs
@@ -1,13 +1,19 @@
-using System.Collections;
+using Nodify.Interactivity;
+using System.Collections;
+using System.Collections.Generic;
using System.Collections.Specialized;
+using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
+using System.Windows.Input;
namespace Nodify
{
- internal sealed class ConnectionsMultiSelector : MultiSelector
+ public class ConnectionsMultiSelector : MultiSelector, IKeyboardNavigationLayer
{
+ #region Dependency Properties
+
public static readonly DependencyProperty SelectedItemsProperty = NodifyEditor.SelectedItemsProperty.AddOwner(typeof(ConnectionsMultiSelector), new FrameworkPropertyMetadata(default(IList), OnSelectedItemsSourceChanged));
public static readonly DependencyProperty CanSelectMultipleItemsProperty = NodifyEditor.CanSelectMultipleItemsProperty.AddOwner(typeof(ConnectionsMultiSelector), new FrameworkPropertyMetadata(BoxValue.True, OnCanSelectMultipleItemsChanged, CoerceCanSelectMultipleItems));
@@ -44,17 +50,132 @@ private bool CanSelectMultipleItemsBase
set => base.CanSelectMultipleItems = value;
}
+ #endregion
+
///
/// Gets the that owns this .
///
public NodifyEditor? Editor { get; private set; }
+ ///
+ /// Gets a list of all s.
+ ///
+ /// Cache the result before using it to avoid extra allocations.
+ protected internal IReadOnlyCollection ConnectionContainers
+ {
+ get
+ {
+ ItemCollection items = Items;
+ var containers = new List(items.Count);
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ containers.Add((ConnectionContainer)ItemContainerGenerator.ContainerFromIndex(i));
+ }
+
+ return containers;
+ }
+ }
+
+ static ConnectionsMultiSelector()
+ {
+ FocusableProperty.OverrideMetadata(typeof(ConnectionsMultiSelector), new FrameworkPropertyMetadata(BoxValue.False));
+
+ KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(ConnectionsMultiSelector), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.ControlTabNavigationProperty.OverrideMetadata(typeof(ConnectionsMultiSelector), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(ConnectionsMultiSelector), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ }
+
+ public ConnectionsMultiSelector()
+ {
+ _focusNavigator = new StatefulFocusNavigator(OnElementFocused);
+ }
+
protected override DependencyObject GetContainerForItemOverride()
=> new ConnectionContainer(this);
protected override bool IsItemItsOwnContainerOverride(object item)
=> item is ConnectionContainer;
+ public override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ Editor = this.GetParentOfType();
+
+ if (NodifyEditor.AutoRegisterConnectionsLayer)
+ {
+ Editor?.RegisterNavigationLayer(this);
+ }
+ }
+
+ #region Keyboard Navigation
+
+ public KeyboardNavigationLayerId Id { get; } = KeyboardNavigationLayerId.Connections;
+ public IKeyboardFocusTarget? LastFocusedElement => _focusNavigator.LastFocusedElement;
+
+ private readonly StatefulFocusNavigator _focusNavigator;
+
+ public bool TryMoveFocus(TraversalRequest request)
+ {
+ return _focusNavigator.TryMoveFocus(request, TryFindContainerToFocus);
+ }
+
+ public bool TryRestoreFocus()
+ {
+ return _focusNavigator.TryRestoreFocus();
+ }
+
+ private bool TryFindContainerToFocus(ConnectionContainer? currentElement, TraversalRequest request, out ConnectionContainer? containerToFocus)
+ {
+ containerToFocus = null;
+
+ if (currentElement is ConnectionContainer focusedContainer)
+ {
+ containerToFocus = FindNextFocusTarget(focusedContainer, request);
+ }
+ else if (currentElement is UIElement elem && elem.GetParentOfType() is ConnectionContainer parentContainer)
+ {
+ containerToFocus = parentContainer;
+ }
+ else if (Items.Count > 0 && Editor != null)
+ {
+ var viewport = new Rect(Editor.ViewportLocation, Editor.ViewportSize);
+ var containers = ConnectionContainers;
+ containerToFocus = containers.FirstOrDefault(container => viewport.IntersectsWith(((IKeyboardFocusTarget)container).Bounds))
+ ?? containers.First();
+ }
+
+ return containerToFocus != null;
+ }
+
+ protected virtual ConnectionContainer? FindNextFocusTarget(ConnectionContainer currentContainer, TraversalRequest request)
+ {
+ var focusNavigator = new DirectionalFocusNavigator(ConnectionContainers);
+ var result = focusNavigator.FindNextFocusTarget(currentContainer, request);
+
+ return result?.Element;
+ }
+
+ protected virtual void OnElementFocused(IKeyboardFocusTarget target)
+ {
+ if (NodifyEditor.AutoPanOnNodeFocus)
+ {
+ Editor?.BringIntoView(target.Bounds, NodifyEditor.BringIntoViewEdgeOffset);
+ }
+ }
+
+ void IKeyboardNavigationLayer.OnActivated()
+ {
+ TryRestoreFocus();
+ }
+
+ void IKeyboardNavigationLayer.OnDeactivated()
+ {
+ }
+
+ #endregion
+
public void Select(ConnectionContainer container)
{
BeginUpdateSelectedItems();
@@ -75,13 +196,6 @@ public void Select(ConnectionContainer container)
Editor?.UnselectAll();
}
- public override void OnApplyTemplate()
- {
- base.OnApplyTemplate();
-
- Editor = this.GetParentOfType();
- }
-
#region Selection Handlers
private void OnSelectedItemsSourceChanged(IList oldValue, IList newValue)
diff --git a/Nodify/Connectors/PendingConnection.cs b/Nodify/Connectors/PendingConnection.cs
index f238545a..d1b56dca 100644
--- a/Nodify/Connectors/PendingConnection.cs
+++ b/Nodify/Connectors/PendingConnection.cs
@@ -241,6 +241,8 @@ public ICommand? CompletedCommand
static PendingConnection()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(PendingConnection), new FrameworkPropertyMetadata(typeof(PendingConnection)));
+ IsHitTestVisibleProperty.OverrideMetadata(typeof(PendingConnection), new FrameworkPropertyMetadata(BoxValue.False));
+ IsEnabledProperty.OverrideMetadata(typeof(PendingConnection), new FrameworkPropertyMetadata(BoxValue.False));
}
///
diff --git a/Nodify/Connectors/States/Disconnect.cs b/Nodify/Connectors/States/Disconnect.cs
index a2d7e65a..e43676f6 100644
--- a/Nodify/Connectors/States/Disconnect.cs
+++ b/Nodify/Connectors/States/Disconnect.cs
@@ -36,6 +36,16 @@ protected override void OnMouseUp(MouseButtonEventArgs e)
e.Handled = true; // prevent opening context menu
}
}
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ EditorGestures.ConnectorGestures gestures = EditorGestures.Mappings.Connector;
+ if (gestures.Disconnect.Matches(e.Source, e))
+ {
+ Element.RemoveConnections();
+ e.Handled = true;
+ }
+ }
}
}
}
diff --git a/Nodify/Containers/DecoratorContainer.cs b/Nodify/Containers/DecoratorContainer.cs
index 5416774f..10941306 100644
--- a/Nodify/Containers/DecoratorContainer.cs
+++ b/Nodify/Containers/DecoratorContainer.cs
@@ -1,12 +1,15 @@
-using System.Windows;
+using Nodify.Interactivity;
+using System.Windows;
using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
namespace Nodify
{
///
/// The container for all the items generated from the collection.
///
- public class DecoratorContainer : ContentControl, INodifyCanvasItem
+ public class DecoratorContainer : ContentControl, INodifyCanvasItem, IKeyboardFocusTarget
{
#region Dependency Properties
@@ -62,15 +65,48 @@ protected void OnLocationChanged()
#endregion
+ public Rect Bounds => new Rect(Location, ActualSize);
+ DecoratorContainer IKeyboardFocusTarget.Element => this;
+
+ private DecoratorsControl? _owner;
+ public DecoratorsControl? Owner => _owner ??= this.GetParentOfType();
+
static DecoratorContainer()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(DecoratorContainer), new FrameworkPropertyMetadata(typeof(DecoratorContainer)));
+ FocusableProperty.OverrideMetadata(typeof(DecoratorContainer), new FrameworkPropertyMetadata(BoxValue.True));
+
+ KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(DecoratorContainer), new FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle));
+ KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(DecoratorContainer), new FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle));
+ }
+
+ public DecoratorContainer(DecoratorsControl parent)
+ {
+ _owner = parent;
+ }
+
+ public DecoratorContainer()
+ {
}
///
protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
{
- ActualSize = sizeInfo.NewSize;
+ SetCurrentValue(ActualSizeProperty, sizeInfo.NewSize);
+ }
+
+ protected override void OnVisualParentChanged(DependencyObject oldParent)
+ {
+ if (VisualTreeHelper.GetParent(this) == null && IsKeyboardFocusWithin)
+ {
+ base.OnVisualParentChanged(oldParent);
+
+ Owner?.Editor?.Focus();
+ }
+ else
+ {
+ base.OnVisualParentChanged(oldParent);
+ }
}
}
}
diff --git a/Nodify/Containers/DecoratorsControl.cs b/Nodify/Containers/DecoratorsControl.cs
index 5d9bef90..15d7ce3d 100644
--- a/Nodify/Containers/DecoratorsControl.cs
+++ b/Nodify/Containers/DecoratorsControl.cs
@@ -1,19 +1,141 @@
-using System.Windows;
+using Nodify.Interactivity;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
using System.Windows.Controls;
+using System.Windows.Input;
namespace Nodify
{
///
/// An that works with s.
///
- internal sealed class DecoratorsControl : ItemsControl
+ public class DecoratorsControl : ItemsControl, IKeyboardNavigationLayer
{
+ ///
+ /// Gets the that owns this .
+ ///
+ public NodifyEditor? Editor { get; private set; }
+
+ ///
+ /// Gets a list of all s.
+ ///
+ /// Cache the result before using it to avoid extra allocations.
+ protected internal IReadOnlyCollection DecoratorContainers
+ {
+ get
+ {
+ ItemCollection items = Items;
+ var containers = new List(items.Count);
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ containers.Add((DecoratorContainer)ItemContainerGenerator.ContainerFromIndex(i));
+ }
+
+ return containers;
+ }
+ }
+
+ static DecoratorsControl()
+ {
+ FocusableProperty.OverrideMetadata(typeof(DecoratorsControl), new FrameworkPropertyMetadata(BoxValue.False));
+
+ KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(DecoratorsControl), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.ControlTabNavigationProperty.OverrideMetadata(typeof(DecoratorsControl), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(DecoratorsControl), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ }
+
+ public DecoratorsControl()
+ {
+ _focusNavigator = new StatefulFocusNavigator(OnElementFocused);
+ }
+
///
protected override bool IsItemItsOwnContainerOverride(object item)
=> item is DecoratorContainer;
///
protected override DependencyObject GetContainerForItemOverride()
- => new DecoratorContainer();
+ => new DecoratorContainer(this);
+
+ public override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ Editor = this.GetParentOfType();
+
+ if (NodifyEditor.AutoRegisterDecoratorsLayer)
+ {
+ Editor?.RegisterNavigationLayer(this);
+ }
+ }
+
+ #region Keyboard Navigation
+
+ public KeyboardNavigationLayerId Id { get; } = KeyboardNavigationLayerId.Decorators;
+ public IKeyboardFocusTarget? LastFocusedElement => _focusNavigator.LastFocusedElement;
+
+ private readonly StatefulFocusNavigator _focusNavigator;
+
+ public bool TryMoveFocus(TraversalRequest request)
+ {
+ return _focusNavigator.TryMoveFocus(request, TryFindContainerToFocus);
+ }
+
+ public bool TryRestoreFocus()
+ {
+ return _focusNavigator.TryRestoreFocus();
+ }
+
+ private bool TryFindContainerToFocus(DecoratorContainer? currentElement, TraversalRequest request, out DecoratorContainer? containerToFocus)
+ {
+ containerToFocus = null;
+
+ if (currentElement is DecoratorContainer focusedContainer)
+ {
+ containerToFocus = FindNextFocusTarget(focusedContainer, request);
+ }
+ else if (currentElement is UIElement elem && elem.GetParentOfType() is DecoratorContainer parentContainer)
+ {
+ containerToFocus = parentContainer;
+ }
+ else if (Items.Count > 0 && Editor != null)
+ {
+ var viewport = new Rect(Editor.ViewportLocation, Editor.ViewportSize);
+ var containers = DecoratorContainers;
+ containerToFocus = containers.FirstOrDefault(container => viewport.IntersectsWith(((IKeyboardFocusTarget)container).Bounds))
+ ?? containers.First();
+ }
+
+ return containerToFocus != null;
+ }
+
+ protected virtual DecoratorContainer? FindNextFocusTarget(DecoratorContainer currentContainer, TraversalRequest request)
+ {
+ var focusNavigator = new DirectionalFocusNavigator(DecoratorContainers);
+ var result = focusNavigator.FindNextFocusTarget(currentContainer, request);
+
+ return result?.Element;
+ }
+
+ protected virtual void OnElementFocused(IKeyboardFocusTarget target)
+ {
+ if (NodifyEditor.AutoPanOnNodeFocus)
+ {
+ Editor?.BringIntoView(target.Bounds, NodifyEditor.BringIntoViewEdgeOffset);
+ }
+ }
+
+ void IKeyboardNavigationLayer.OnActivated()
+ {
+ TryRestoreFocus();
+ }
+
+ void IKeyboardNavigationLayer.OnDeactivated()
+ {
+ }
+
+ #endregion
}
}
diff --git a/Nodify/Containers/ItemContainer.cs b/Nodify/Containers/ItemContainer.cs
index 831ce8ee..5580ca1a 100644
--- a/Nodify/Containers/ItemContainer.cs
+++ b/Nodify/Containers/ItemContainer.cs
@@ -11,7 +11,7 @@ namespace Nodify
///
/// The container for all the items generated by the of the .
///
- public class ItemContainer : ContentControl, INodifyCanvasItem
+ public class ItemContainer : ContentControl, INodifyCanvasItem, IKeyboardFocusTarget
{
#region Dependency Properties
@@ -247,6 +247,13 @@ private static void OnIsSelectedChanged(DependencyObject d, DependencyPropertyCh
BorderThickness.Right - SelectedBorderThickness.Right,
BorderThickness.Bottom - SelectedBorderThickness.Bottom);
+ ///
+ /// Gets the bounds of the selection area for this based on its and .
+ ///
+ public Rect Bounds => new Rect(Location, DesiredSizeForSelection ?? RenderSize);
+
+ ItemContainer IKeyboardFocusTarget.Element => this;
+
#endregion
///
@@ -268,6 +275,9 @@ static ItemContainer()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(ItemContainer), new FrameworkPropertyMetadata(typeof(ItemContainer)));
FocusableProperty.OverrideMetadata(typeof(ItemContainer), new FrameworkPropertyMetadata(BoxValue.True));
+
+ KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(ItemContainer), new FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle));
+ KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(ItemContainer), new FrameworkPropertyMetadata(KeyboardNavigationMode.Cycle));
}
///
@@ -281,6 +291,20 @@ public ItemContainer(NodifyEditor editor)
InputProcessor.AddSharedHandlers(this);
}
+ protected override void OnVisualParentChanged(DependencyObject oldParent)
+ {
+ if (VisualTreeHelper.GetParent(this) == null && IsKeyboardFocusWithin)
+ {
+ base.OnVisualParentChanged(oldParent);
+
+ Editor.Focus();
+ }
+ else
+ {
+ base.OnVisualParentChanged(oldParent);
+ }
+ }
+
///
protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
{
@@ -295,7 +319,7 @@ protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
/// True if is selectable.
protected internal virtual bool IsSelectableLocation(Point position)
{
- Size size = DesiredSizeForSelection ?? RenderSize;
+ Size size = Bounds.Size;
return position.X >= 0 && position.Y >= 0 && position.X <= size.Width && position.Y <= size.Height;
}
@@ -307,8 +331,7 @@ protected internal virtual bool IsSelectableLocation(Point position)
/// True if contains or intersects this .
public virtual bool IsSelectableInArea(Rect area, bool isContained)
{
- var bounds = new Rect(Location, DesiredSizeForSelection ?? RenderSize);
- return isContained ? area.Contains(bounds) : area.IntersectsWith(bounds);
+ return isContained ? area.Contains(Bounds) : area.IntersectsWith(Bounds);
}
///
@@ -370,7 +393,7 @@ protected override void OnMouseDown(MouseButtonEventArgs e)
protected override void OnMouseUp(MouseButtonEventArgs e)
{
InputProcessor.ProcessEvent(e);
-
+
// Release the mouse capture if all the mouse buttons are released and there's no interaction in progress
if (!InputProcessor.RequiresInputCapture && IsMouseCaptured && e.RightButton == MouseButtonState.Released && e.LeftButton == MouseButtonState.Released && e.MiddleButton == MouseButtonState.Released)
{
diff --git a/Nodify/CuttingLine/CuttingLine.cs b/Nodify/CuttingLine/CuttingLine.cs
index 1d152f5d..81f143f9 100644
--- a/Nodify/CuttingLine/CuttingLine.cs
+++ b/Nodify/CuttingLine/CuttingLine.cs
@@ -61,6 +61,7 @@ static CuttingLine()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(CuttingLine), new FrameworkPropertyMetadata(typeof(CuttingLine)));
IsHitTestVisibleProperty.OverrideMetadata(typeof(CuttingLine), new FrameworkPropertyMetadata(BoxValue.False));
+ IsEnabledProperty.OverrideMetadata(typeof(CuttingLine), new FrameworkPropertyMetadata(BoxValue.False));
}
protected override void OnRender(DrawingContext drawingContext)
diff --git a/Nodify/Editor/EditorCommands.cs b/Nodify/Editor/EditorCommands.cs
index 09429e9f..83bc0f8d 100644
--- a/Nodify/Editor/EditorCommands.cs
+++ b/Nodify/Editor/EditorCommands.cs
@@ -41,7 +41,7 @@ public static class EditorCommands
///
public static RoutedUICommand BringIntoView { get; } = new RoutedUICommand("Bring location into view", nameof(BringIntoView), typeof(EditorCommands), new InputGestureCollection
{
- EditorGestures.Mappings.Editor.ResetViewportLocation
+ EditorGestures.Mappings.Editor.ResetViewport
});
///
@@ -160,7 +160,7 @@ private static void OnBringIntoView(object sender, ExecutedRoutedEventArgs e)
editor.BringIntoView(Point.Parse(str));
break;
default:
- editor.BringIntoView(new Point());
+ editor.ResetViewport();
break;
}
}
diff --git a/Nodify/Editor/NodifyEditor.KeyboardNavigation.cs b/Nodify/Editor/NodifyEditor.KeyboardNavigation.cs
new file mode 100644
index 00000000..06e41629
--- /dev/null
+++ b/Nodify/Editor/NodifyEditor.KeyboardNavigation.cs
@@ -0,0 +1,278 @@
+using Nodify.Interactivity;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows.Input;
+using System.Windows;
+using System.Collections;
+using System.Diagnostics;
+
+namespace Nodify
+{
+ public partial class NodifyEditor : IKeyboardNavigationLayer, IKeyboardNavigationLayerGroup
+ {
+ ///
+ /// Gets or sets the default viewport edge offset applied when bringing an item into view as a result of keyboard focus.
+ ///
+ public static double BringIntoViewEdgeOffset { get; set; } = 32d;
+
+ ///
+ /// Automatically focus the first container when the navigation layer changes or the editor gets focused.
+ ///
+ public static bool AutoFocusFirstElement { get; set; } = true;
+
+ ///
+ /// Automatically pan the viewport when a node is focused via keyboard navigation.
+ ///
+ public static bool AutoPanOnNodeFocus { get; set; } = true;
+
+ ///
+ /// Automatically registers the decorators layer for keyboard navigation.
+ ///
+ public static bool AutoRegisterDecoratorsLayer { get; set; }
+
+ ///
+ /// Automatically registers the connectors layer for keyboard navigation.
+ ///
+ public static bool AutoRegisterConnectionsLayer { get; set; } = true;
+
+ ///
+ /// Indicates whether the viewport should automatically pan to follow elements moved via keyboard dragging.
+ ///
+ public static bool PanViewportOnKeyboardDrag { get; set; } = true;
+
+ ///
+ /// Defines the minimum distance to move or navigate when using directional input (such as arrow keys), scaled by the .
+ /// If the is smaller than this value, the movement step is increased to the nearest greater multiple of the .
+ ///
+ public static double MinimumNavigationStepSize { get; set; } = 10d;
+
+ public IKeyboardNavigationLayer? ActiveNavigationLayer => _activeKeyboardNavigationLayer;
+ public IKeyboardNavigationLayer KeyboardNavigationLayer => this;
+
+ KeyboardNavigationLayerId IKeyboardNavigationLayer.Id => KeyboardNavigationLayerId.Nodes;
+ IKeyboardFocusTarget? IKeyboardNavigationLayer.LastFocusedElement => _focusNavigator.LastFocusedElement;
+
+ int IReadOnlyCollection.Count => _navigationLayers.Count;
+
+ private readonly List _navigationLayers = new List();
+ private IKeyboardNavigationLayer? _activeKeyboardNavigationLayer;
+
+ #region Focus Handling
+
+ private readonly StatefulFocusNavigator _focusNavigator;
+
+ public event Action? ActiveNavigationLayerChanged;
+
+ bool IKeyboardNavigationLayer.TryMoveFocus(TraversalRequest request)
+ {
+ return _focusNavigator.TryMoveFocus(request, TryFindContainerToFocus);
+ }
+
+ bool IKeyboardNavigationLayer.TryRestoreFocus()
+ {
+ return _focusNavigator.TryRestoreFocus();
+ }
+
+ private bool TryFindContainerToFocus(ItemContainer? currentElement, TraversalRequest request, out ItemContainer? containerToFocus)
+ {
+ containerToFocus = null;
+
+ if (currentElement is ItemContainer focusedContainer)
+ {
+ containerToFocus = FindNextFocusTarget(focusedContainer, request);
+ }
+ // The current element is not a nested editor, but a focusable element inside an ItemContainer
+ else if (currentElement is UIElement elem && elem != this && elem.GetParentOfType() is ItemContainer parentContainer)
+ {
+ containerToFocus = parentContainer;
+ }
+ else if (Items.Count > 0)
+ {
+ var viewport = new Rect(ViewportLocation, ViewportSize);
+ var containers = ItemContainers;
+ containerToFocus = containers.FirstOrDefault(container => container.IsSelectableInArea(viewport, isContained: false))
+ ?? containers.First();
+ }
+
+ return containerToFocus != null;
+ }
+
+ protected virtual ItemContainer? FindNextFocusTarget(ItemContainer currentContainer, TraversalRequest request)
+ {
+ var focusNavigator = new DirectionalFocusNavigator(ItemContainers);
+ var result = focusNavigator.FindNextFocusTarget(currentContainer, request);
+
+ return result?.Element;
+ }
+
+ protected virtual void OnElementFocused(IKeyboardFocusTarget target)
+ {
+ if (AutoPanOnNodeFocus)
+ {
+ BringIntoView(target.Bounds, BringIntoViewEdgeOffset);
+ }
+ }
+
+ public bool MoveFocus(FocusNavigationDirection direction)
+ => MoveFocus(new TraversalRequest(direction));
+
+ public new bool MoveFocus(TraversalRequest request)
+ => ActiveNavigationLayer?.TryMoveFocus(request) ?? false;
+
+ void IKeyboardNavigationLayer.OnActivated()
+ {
+ KeyboardNavigationLayer.TryRestoreFocus();
+ }
+
+ void IKeyboardNavigationLayer.OnDeactivated()
+ {
+ }
+
+ protected override void OnLostKeyboardFocus(KeyboardFocusChangedEventArgs e)
+ {
+ bool isKeyboardInitiated = InputManager.Current.MostRecentInputDevice is KeyboardDevice;
+
+ // When any focusable elements inside the editor - that are most likely inside containers (textbox, checkbox etc) - lose focus,
+ // and the focus goes outside the editor, we must focus its container first, otherwise focus the editor (don't allow focus to escape)
+ if (isKeyboardInitiated && e.OldFocus is DependencyObject oldFocus && !IsNavigationTrigger(oldFocus) && IsAncestorOf(oldFocus) && (e.NewFocus is DependencyObject newFocus && !IsAncestorOf(newFocus)))
+ {
+ var container = oldFocus.GetParent(IsNavigationTrigger);
+ if (container is UIElement elem && elem.Focus())
+ {
+ e.Handled = true;
+ }
+ else
+ {
+ e.Handled = Focus();
+ }
+ }
+ }
+
+ protected override void OnGotKeyboardFocus(KeyboardFocusChangedEventArgs e)
+ {
+ bool isKeyboardInitiated = InputManager.Current.MostRecentInputDevice is KeyboardDevice;
+
+ if (isKeyboardInitiated && ActiveNavigationLayer != null)
+ {
+ bool isFocusComingFromOutside = e.OldFocus is null || e.OldFocus is DependencyObject dpo && !IsAncestorOf(dpo);
+
+ if (isFocusComingFromOutside && ActiveNavigationLayer.TryRestoreFocus())
+ {
+ e.Handled = true;
+ }
+ else if (ActiveNavigationLayer.LastFocusedElement is null && e.NewFocus == this && AutoFocusFirstElement)
+ {
+ e.Handled = ActiveNavigationLayer.TryMoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
+ }
+ }
+ }
+
+ protected internal virtual bool IsNavigationTrigger(DependencyObject? dp)
+ {
+ return dp is NodifyEditor || dp is ItemContainer || dp is ConnectionContainer || dp is DecoratorContainer;
+ }
+
+ #endregion
+
+ #region Layer Management
+
+ protected virtual void OnKeyboardNavigationLayerActivated(IKeyboardNavigationLayer activeLayer)
+ {
+ if (AutoFocusFirstElement && !activeLayer!.TryRestoreFocus() && HandleNestedEditor())
+ {
+ activeLayer.TryMoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
+ }
+
+ bool HandleNestedEditor()
+ {
+ var parentEditor = this.GetParentOfType();
+ return parentEditor is null || parentEditor.IsKeyboardFocusWithin;
+ }
+ }
+
+ public bool RegisterNavigationLayer(IKeyboardNavigationLayer layer)
+ {
+ if (_navigationLayers.Any(l => l.Id == layer.Id))
+ {
+ return false;
+ }
+
+ _navigationLayers.Add(layer);
+
+ Debug.WriteLine($"Registered {layer} as a keyboard navigation layer in {this}");
+
+ return true;
+ }
+
+ public bool RemoveNavigationLayer(KeyboardNavigationLayerId layerId)
+ {
+ var layerToRemove = _navigationLayers.FirstOrDefault(layer => layer.Id == layerId);
+ if (layerToRemove != null && _navigationLayers.Remove(layerToRemove))
+ {
+ ActivatePreviousNavigationLayer();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool ActivateNavigationLayer(KeyboardNavigationLayerId layerId)
+ {
+ var newLayer = _navigationLayers.FirstOrDefault(x => x.Id == layerId);
+ if (newLayer != null)
+ {
+ var prevLayer = _activeKeyboardNavigationLayer;
+ _activeKeyboardNavigationLayer = newLayer;
+ prevLayer?.OnDeactivated();
+ newLayer.OnActivated();
+ OnKeyboardNavigationLayerActivated(newLayer);
+ Debug.WriteLine($"Activated {_activeKeyboardNavigationLayer} as a keyboard navigation layer in {this}");
+ ActiveNavigationLayerChanged?.Invoke(layerId);
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool ActivateNextNavigationLayer()
+ {
+ if (_navigationLayers.Count > 0)
+ {
+ Debug.Assert(ActiveNavigationLayer != null);
+
+ int currentIndex = _navigationLayers.IndexOf(ActiveNavigationLayer!);
+ int nextIndex = (currentIndex + 1) % _navigationLayers.Count;
+
+ var layer = _navigationLayers[nextIndex];
+ return ActivateNavigationLayer(layer.Id);
+ }
+
+ return false;
+ }
+
+ public bool ActivatePreviousNavigationLayer()
+ {
+ if (_navigationLayers.Count > 0)
+ {
+ Debug.Assert(ActiveNavigationLayer != null);
+
+ int currentIndex = _navigationLayers.IndexOf(ActiveNavigationLayer!);
+ int prevIndex = (currentIndex - 1 + _navigationLayers.Count) % _navigationLayers.Count;
+ var layer = _navigationLayers[prevIndex];
+ return ActivateNavigationLayer(layer.Id);
+ }
+
+ return false;
+ }
+
+ public IEnumerator GetEnumerator()
+ => _navigationLayers.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator()
+ => GetEnumerator();
+
+ #endregion
+ }
+}
diff --git a/Nodify/Editor/NodifyEditor.cs b/Nodify/Editor/NodifyEditor.cs
index 0c1d50b8..2d7a5585 100644
--- a/Nodify/Editor/NodifyEditor.cs
+++ b/Nodify/Editor/NodifyEditor.cs
@@ -552,6 +552,10 @@ static NodifyEditor()
DefaultStyleKeyProperty.OverrideMetadata(typeof(NodifyEditor), new FrameworkPropertyMetadata(typeof(NodifyEditor)));
FocusableProperty.OverrideMetadata(typeof(NodifyEditor), new FrameworkPropertyMetadata(BoxValue.True));
+ KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(NodifyEditor), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.ControlTabNavigationProperty.OverrideMetadata(typeof(NodifyEditor), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(NodifyEditor), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+
EditorCommands.RegisterCommandBindings();
}
@@ -574,7 +578,10 @@ public NodifyEditor()
InputProcessor.AddSharedHandlers(this);
+ Loaded += OnEditorLoaded;
Unloaded += OnEditorUnloaded;
+
+ _focusNavigator = new StatefulFocusNavigator(OnElementFocused);
}
///
@@ -588,6 +595,13 @@ public override void OnApplyTemplate()
OnDisableAutoPanningChanged(DisableAutoPanning);
}
+ private void OnEditorLoaded(object sender, RoutedEventArgs e)
+ {
+ // It's safe to call RegisterNavigationLayer multiple times. It only registers once for the same id.
+ RegisterNavigationLayer(this);
+ ActivateNavigationLayer(KeyboardNavigationLayer.Id);
+ }
+
private void OnEditorUnloaded(object sender, RoutedEventArgs e)
{
OnDisableAutoPanningChanged(true);
@@ -609,12 +623,12 @@ protected override bool IsItemItsOwnContainerOverride(object item)
#region Methods
///
- /// Zoom in at the viewports center
+ /// Zoom in at the viewport's center.
///
public void ZoomIn() => ZoomAtPosition(Math.Pow(2.0, 120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine), ViewportLocation + (Vector)ViewportSize / 2);
///
- /// Zoom out at the viewports center
+ /// Zoom out at the viewport's center.
///
public void ZoomOut() => ZoomAtPosition(Math.Pow(2.0, -120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine), ViewportLocation + (Vector)ViewportSize / 2);
@@ -659,8 +673,8 @@ public void BringIntoView(Point point, bool animated = true, Action? onFinish =
if (animated && newLocation != ViewportLocation)
{
BeginPanning();
- DisablePanning = true;
- DisableZooming = true;
+ SetCurrentValue(DisablePanningProperty, true);
+ SetCurrentValue(DisableZoomingProperty, true);
double distance = (newLocation - ViewportLocation).Length;
double duration = distance / (BringIntoViewSpeed + (distance / 10)) * ViewportZoom;
@@ -669,15 +683,15 @@ public void BringIntoView(Point point, bool animated = true, Action? onFinish =
this.StartAnimation(ViewportLocationProperty, newLocation, duration, (s, e) =>
{
EndPanning();
- DisablePanning = false;
- DisableZooming = false;
+ SetCurrentValue(DisablePanningProperty, false);
+ SetCurrentValue(DisableZoomingProperty, false);
onFinish?.Invoke();
});
}
else
{
- ViewportLocation = newLocation;
+ SetCurrentValue(ViewportLocationProperty, newLocation);
onFinish?.Invoke();
}
}
@@ -689,6 +703,75 @@ public void BringIntoView(Point point, bool animated = true, Action? onFinish =
public new void BringIntoView(Rect area)
=> BringIntoView(new Point(area.X + area.Width / 2, area.Y + area.Height / 2));
+ ///
+ /// Ensures the specified item container is fully visible within the viewport, optionally with padding around the edges.
+ ///
+ /// The item container to bring into view.
+ /// The padding to apply around the container
+ public void BringIntoView(Rect area, double offsetFromEdge = 32d)
+ {
+ var viewport = new Rect(ViewportLocation, ViewportSize);
+
+ area.Inflate(offsetFromEdge, offsetFromEdge);
+
+ if (!viewport.Contains(area))
+ {
+ if (viewport.IntersectsWith(area))
+ {
+ double newX = viewport.X;
+ double newY = viewport.Y;
+
+ if (area.Left < viewport.Left)
+ {
+ newX = area.Left;
+ }
+ else if (area.Right > viewport.Right)
+ {
+ newX = area.Right - viewport.Width;
+ }
+
+ if (area.Top < viewport.Top)
+ {
+ newY = area.Top;
+ }
+ else if (area.Bottom > viewport.Bottom)
+ {
+ newY = area.Bottom - viewport.Height;
+ }
+
+ BringIntoView(new Point(newX, newY) + new Vector(viewport.Width / 2, viewport.Height / 2));
+ }
+ else
+ {
+ BringIntoView(area);
+ }
+ }
+ }
+
+ ///
+ /// Reset the viewport location to (0, 0) and the viewport zoom to 1.
+ ///
+ /// Whether the viewport transition is animated.
+ /// The callback invoked when the viewport transition is finished.
+ public void ResetViewport(bool animated = true, Action? onFinish = null)
+ {
+ BringIntoView(new Point(ViewportSize.Width / 2, ViewportSize.Height / 2), animated, () =>
+ {
+ if (animated)
+ {
+ this.StartAnimation(ViewportZoomProperty, 1d, BringIntoViewMaxDuration, (s, e) =>
+ {
+ onFinish?.Invoke();
+ });
+ }
+ else
+ {
+ SetCurrentValue(ViewportZoomProperty, BoxValue.Double1);
+ onFinish?.Invoke();
+ }
+ });
+ }
+
///
/// Scales the viewport to fit the specified or all the s if that's possible.
///
diff --git a/Nodify/Editor/States/EditorState.cs b/Nodify/Editor/States/EditorState.cs
index b2f0bc03..7ae4d5bd 100644
--- a/Nodify/Editor/States/EditorState.cs
+++ b/Nodify/Editor/States/EditorState.cs
@@ -65,6 +65,7 @@ internal static void RegisterDefaultHandlers()
InputProcessor.Shared.RegisterHandlerFactory(elem => new Zooming(elem));
InputProcessor.Shared.RegisterHandlerFactory(elem => new PushingItems(elem));
InputProcessor.Shared.RegisterHandlerFactory(elem => new Cutting(elem));
+ InputProcessor.Shared.RegisterHandlerFactory(elem => new KeyboardNavigation(elem));
}
}
}
diff --git a/Nodify/Editor/States/KeyboardNavigation.cs b/Nodify/Editor/States/KeyboardNavigation.cs
new file mode 100644
index 00000000..a2898001
--- /dev/null
+++ b/Nodify/Editor/States/KeyboardNavigation.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Windows;
+using System.Windows.Input;
+
+namespace Nodify.Interactivity
+{
+ public static partial class EditorState
+ {
+ ///
+ /// Represents the keyboard navigation state of the , allowing users to navigate and interact with nodes and connections using the keyboard.
+ ///
+ public class KeyboardNavigation : InputElementState
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The associated with this state.
+ public KeyboardNavigation(NodifyEditor element) : base(element)
+ {
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ if (!Element.IsKeyboardFocusWithin || !(e.OriginalSource is DependencyObject originalSource))
+ {
+ return;
+ }
+
+ double navigationStepSize = GetNavigationStepSize();
+ var gestures = EditorGestures.Mappings.Editor.Keyboard;
+
+ if (e.Key == Key.Tab && Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
+ {
+ var parentContainer = originalSource.GetParent(Element.IsNavigationTrigger) as UIElement;
+ e.Handled = parentContainer?.Focus() is true;
+ }
+ else if (Element.IsNavigationTrigger(originalSource))
+ {
+ if (gestures.Pan.TryGetNavigationDirection(e, out var panDirection))
+ {
+ var panning = new Vector(-panDirection.X * navigationStepSize, panDirection.Y * navigationStepSize);
+ Element.UpdatePanning(panning);
+ e.Handled = true;
+ }
+ else if (CanDragSelection() && gestures.DragSelection.TryGetNavigationDirection(e, out var dragDirection))
+ {
+ var dragging = new Vector(dragDirection.X * navigationStepSize, -dragDirection.Y * navigationStepSize);
+ Element.BeginDragging();
+ Element.UpdateDragging(dragging);
+ Element.EndDragging();
+
+ if (NodifyEditor.PanViewportOnKeyboardDrag)
+ {
+ var panning = new Vector(-dragDirection.X * navigationStepSize, dragDirection.Y * navigationStepSize);
+ Element.UpdatePanning(panning);
+ }
+
+ e.Handled = true;
+ }
+ else if (gestures.NavigateSelection.TryGetFocusDirection(e, out var direction))
+ {
+ Element.MoveFocus(direction);
+ e.Handled = true;
+ }
+ }
+ }
+
+ protected override void OnKeyUp(KeyEventArgs e)
+ {
+ var gestures = EditorGestures.Mappings.Editor.Keyboard;
+
+ if (gestures.ToggleSelected.Matches(e.Source, e))
+ {
+ if (Keyboard.FocusedElement is ItemContainer itemContainer)
+ {
+ itemContainer.Select(SelectionType.Invert);
+ if (NodifyEditor.AutoPanOnNodeFocus)
+ {
+ Element.BringIntoView(itemContainer.Bounds, NodifyEditor.BringIntoViewEdgeOffset);
+ }
+ }
+ else if (Keyboard.FocusedElement is ConnectionContainer connectionContainer)
+ {
+ connectionContainer.Select(SelectionType.Invert);
+ if (NodifyEditor.AutoPanOnNodeFocus)
+ {
+ Element.BringIntoView(connectionContainer.Bounds, NodifyEditor.BringIntoViewEdgeOffset);
+ }
+ }
+
+ e.Handled = true;
+ }
+ else if (gestures.DeselectAll.Matches(e.Source, e))
+ {
+ if (Element.SelectedContainersCount > 0 && Element.ActiveNavigationLayer?.Id == KeyboardNavigationLayerId.Nodes)
+ {
+ Element.UnselectAll();
+ e.Handled = true;
+ }
+ // TODO: How to get the selected connections count without a hard reference to the connections multi selector?
+ // This currently assumes we have a binding to the SelectedConnectionsProperty dependency property
+ else if (Element.SelectedConnections?.Count > 0 && Element.ActiveNavigationLayer?.Id == KeyboardNavigationLayerId.Connections)
+ {
+ Element.UnselectAllConnections();
+ e.Handled = true;
+ }
+ }
+ else if (gestures.NextNavigationLayer.Matches(e.Source, e))
+ {
+ Element.ActivateNextNavigationLayer();
+ e.Handled = true;
+ }
+ else if (gestures.PrevNavigationLayer.Matches(e.Source, e))
+ {
+ Element.ActivatePreviousNavigationLayer();
+ e.Handled = true;
+ }
+ else if (Keyboard.FocusedElement is ItemContainer { IsSelected: true } container
+ && EditorGestures.Mappings.GroupingNode.ToggleContentSelection.Matches(e.Source, e))
+ {
+ var groupingNode = container.GetChildOfType();
+ if (groupingNode != null)
+ {
+ groupingNode.ToggleContentSelection();
+ e.Handled = true;
+ }
+ }
+ }
+
+ private bool CanDragSelection()
+ {
+ return Element.ActiveNavigationLayer?.Id == KeyboardNavigationLayerId.Nodes && Element.SelectedContainersCount > 0;
+ }
+
+ private double GetNavigationStepSize()
+ {
+ double cellSize = Element.GridCellSize;
+
+ if (cellSize >= NodifyEditor.MinimumNavigationStepSize)
+ return cellSize;
+
+ int factor = (int)Math.Ceiling(NodifyEditor.MinimumNavigationStepSize / cellSize);
+ return factor * cellSize / Element.ViewportZoom;
+ }
+ }
+ }
+}
diff --git a/Nodify/Interactivity/Gestures/EditorGestures.cs b/Nodify/Interactivity/Gestures/EditorGestures.cs
index cb60c60d..4a6adac9 100644
--- a/Nodify/Interactivity/Gestures/EditorGestures.cs
+++ b/Nodify/Interactivity/Gestures/EditorGestures.cs
@@ -121,7 +121,7 @@ public ItemContainerGestures()
}
/// Gesture to select the container using a strategy.
- /// Defaults to or any of the gestures.
+ /// Defaults to or any of the gestures.
public SelectionGestures Selection { get; }
/// Gesture to start and complete a dragging operation.
@@ -142,13 +142,173 @@ public void Apply(ItemContainerGestures gestures)
Drag.Value = gestures.Drag.Value;
CancelAction.Value = gestures.CancelAction.Value;
}
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ Selection.Unbind();
+ Drag.Unbind();
+ CancelAction.Unbind();
+ }
+ }
+
+ ///
+ /// Keyboard gestures used for navigating the editor and moving selected items.
+ ///
+ public class DirectionalNavigationGestures
+ {
+ public DirectionalNavigationGestures(ModifierKeys modifierKeys = ModifierKeys.None)
+ {
+ Up = new KeyGesture(Key.Up, modifierKeys);
+ Left = new KeyGesture(Key.Left, modifierKeys);
+ Down = new KeyGesture(Key.Down, modifierKeys);
+ Right = new KeyGesture(Key.Right, modifierKeys);
+ }
+
+ public DirectionalNavigationGestures(Key triggerKey, ModifierKeys modifierKeys = ModifierKeys.None, bool repeated = false)
+ {
+ Up = new KeyComboGesture(triggerKey, Key.Up, modifierKeys) { AllowRepeatingComboKey = repeated };
+ Left = new KeyComboGesture(triggerKey, Key.Left, modifierKeys) { AllowRepeatingComboKey = repeated };
+ Down = new KeyComboGesture(triggerKey, Key.Down, modifierKeys) { AllowRepeatingComboKey = repeated };
+ Right = new KeyComboGesture(triggerKey, Key.Right, modifierKeys) { AllowRepeatingComboKey = repeated };
+ }
+
+ ///
+ /// Gesture used for navigating or moving upward.
+ ///
+ public InputGestureRef Up { get; }
+
+ ///
+ /// Gesture used for navigating or moving left.
+ ///
+ public InputGestureRef Left { get; }
+
+ ///
+ /// Gesture used for navigating or moving downward.
+ ///
+ public InputGestureRef Down { get; }
+
+ ///
+ /// Gesture used for navigating or moving right.
+ ///
+ public InputGestureRef Right { get; }
+
+ /// Copies from the specified gestures.
+ /// The gestures to copy.
+ public void Apply(DirectionalNavigationGestures gestures)
+ {
+ Up.Value = gestures.Up.Value;
+ Left.Value = gestures.Left.Value;
+ Down.Value = gestures.Down.Value;
+ Right.Value = gestures.Right.Value;
+ }
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ Up.Unbind();
+ Left.Unbind();
+ Down.Unbind();
+ Right.Unbind();
+ }
}
/// Gestures for the editor.
public class NodifyEditorGestures
{
+ ///
+ /// Keyboard gestures used for navigation, selection, and manipulation in the editor.
+ ///
+ public class KeyboardGestures
+ {
+ public KeyboardGestures()
+ {
+ Pan = new DirectionalNavigationGestures(Key.Space, repeated: true);
+ DragSelection = new DirectionalNavigationGestures(ModifierKeys.Control);
+ NavigateSelection = new DirectionalNavigationGestures(ModifierKeys.None);
+ ToggleSelected = new AnyGesture(new KeyGesture(Key.Space), new KeyGesture(Key.Enter));
+ DeselectAll = new KeyGesture(Key.Escape);
+ NextNavigationLayer = new KeyGesture(Key.OemCloseBrackets, ModifierKeys.Control);
+ PrevNavigationLayer = new KeyGesture(Key.OemOpenBrackets, ModifierKeys.Control);
+ }
+
+ ///
+ /// Directional gestures used for panning the viewport.
+ ///
+ /// Defaults to +arrow keys.
+ public DirectionalNavigationGestures Pan { get; }
+
+ ///
+ /// Directional gestures used for dragging the selected items.
+ ///
+ /// Defaults to +arrow keys.
+ public DirectionalNavigationGestures DragSelection { get; }
+
+ ///
+ /// Directional gestures used to navigate the selection focus (e.g., between nodes).
+ ///
+ /// Defaults to arrow keys.
+ public DirectionalNavigationGestures NavigateSelection { get; }
+
+ ///
+ /// Gesture used to toggle the selected state of the currently focused item.
+ ///
+ /// Defaults to
+ public InputGestureRef ToggleSelected { get; }
+
+ ///
+ /// Gesture used to clear the current selection.
+ ///
+ /// Defaults to .
+ public InputGestureRef DeselectAll { get; }
+
+ ///
+ /// Gesture used to activate the previous keyboard navigation layer.
+ ///
+ /// +.
+ public InputGestureRef NextNavigationLayer { get; }
+
+ ///
+ /// Gesture used to activate the next keyboard navigation layer.
+ ///
+ /// +.
+ public InputGestureRef PrevNavigationLayer { get; }
+
+ /// Copies from the specified gestures.
+ /// The gestures to copy.
+ public void Apply(KeyboardGestures gestures)
+ {
+ Pan.Apply(gestures.Pan);
+ DragSelection.Apply(gestures.DragSelection);
+ NavigateSelection.Apply(gestures.NavigateSelection);
+ ToggleSelected.Value = gestures.ToggleSelected.Value;
+ DeselectAll.Value = gestures.DeselectAll.Value;
+ NextNavigationLayer.Value = gestures.NextNavigationLayer.Value;
+ PrevNavigationLayer.Value = gestures.PrevNavigationLayer.Value;
+ }
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ Pan.Unbind();
+ DragSelection.Unbind();
+ NavigateSelection.Unbind();
+ ToggleSelected.Unbind();
+ DeselectAll.Unbind();
+ NextNavigationLayer.Unbind();
+ PrevNavigationLayer.Unbind();
+ }
+ }
+
public NodifyEditorGestures()
{
+ Keyboard = new KeyboardGestures();
Selection = new SelectionGestures();
SelectAll = ApplicationCommands.SelectAll.InputGestures[0].AsRef();
Cutting = new MouseGesture(MouseAction.LeftClick, ModifierKeys.Alt | ModifierKeys.Shift, true);
@@ -157,7 +317,7 @@ public NodifyEditorGestures()
ZoomModifierKey = ModifierKeys.None;
ZoomIn = new AnyGesture(new KeyGesture(Key.OemPlus, ModifierKeys.Control), new KeyGesture(Key.Add, ModifierKeys.Control));
ZoomOut = new AnyGesture(new KeyGesture(Key.OemMinus, ModifierKeys.Control), new KeyGesture(Key.Subtract, ModifierKeys.Control));
- ResetViewportLocation = new KeyGesture(Key.Home);
+ ResetViewport = new KeyGesture(Key.Home);
FitToScreen = new KeyGesture(Key.Home, ModifierKeys.Shift);
CancelAction = new AnyGesture(new MouseGesture(MouseAction.RightClick), new KeyGesture(Key.Escape));
PanWithMouseWheel = false;
@@ -165,6 +325,8 @@ public NodifyEditorGestures()
PanVerticalModifierKey = ModifierKeys.None;
}
+ public KeyboardGestures Keyboard { get; }
+
/// Gesture used to start selecting using a strategy.
public SelectionGestures Selection { get; }
@@ -206,9 +368,9 @@ public NodifyEditorGestures()
/// Defaults to +.
public InputGestureRef ZoomOut { get; }
- /// Gesture used to move the editor's viewport location to (0, 0).
+ /// Gesture used to move the editor's viewport location to (0, 0) and set the zoom to 1.
/// Defaults to .
- public InputGestureRef ResetViewportLocation { get; }
+ public InputGestureRef ResetViewport { get; }
/// Gesture used to fit as many containers as possible into the viewport.
/// Defaults to +.
@@ -222,6 +384,7 @@ public NodifyEditorGestures()
/// The gestures to copy.
public void Apply(NodifyEditorGestures gestures)
{
+ Keyboard.Apply(gestures.Keyboard);
Selection.Apply(gestures.Selection);
SelectAll.Value = gestures.SelectAll.Value;
Cutting.Value = gestures.Cutting.Value;
@@ -230,13 +393,31 @@ public void Apply(NodifyEditorGestures gestures)
ZoomModifierKey = gestures.ZoomModifierKey;
ZoomIn.Value = gestures.ZoomIn.Value;
ZoomOut.Value = gestures.ZoomOut.Value;
- ResetViewportLocation.Value = gestures.ResetViewportLocation.Value;
+ ResetViewport.Value = gestures.ResetViewport.Value;
FitToScreen.Value = gestures.FitToScreen.Value;
CancelAction.Value = gestures.CancelAction.Value;
PanWithMouseWheel = gestures.PanWithMouseWheel;
PanHorizontalModifierKey = gestures.PanHorizontalModifierKey;
PanVerticalModifierKey = gestures.PanVerticalModifierKey;
}
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ Keyboard.Unbind();
+ Selection.Unbind();
+ SelectAll.Unbind();
+ Cutting.Unbind();
+ Pan.Unbind();
+ PushItems.Unbind();
+ ZoomIn.Unbind();
+ ZoomOut.Unbind();
+ ResetViewport.Unbind();
+ FitToScreen.Unbind();
+ CancelAction.Unbind();
+ }
}
/// Gestures used by the .
@@ -244,13 +425,13 @@ public class ConnectorGestures
{
public ConnectorGestures()
{
- Disconnect = new MouseGesture(MouseAction.LeftClick, ModifierKeys.Alt);
+ Disconnect = new AnyGesture(new MouseGesture(MouseAction.LeftClick, ModifierKeys.Alt), new KeyGesture(Key.Delete));
Connect = new MouseGesture(MouseAction.LeftClick);
CancelAction = new AnyGesture(new MouseGesture(MouseAction.RightClick), new KeyGesture(Key.Escape));
}
/// Gesture to call the .
- /// Defaults to +.
+ /// Defaults to + or .
public InputGestureRef Disconnect { get; }
/// Gesture to start and complete a pending connection.
@@ -269,6 +450,16 @@ public void Apply(ConnectorGestures gestures)
Connect.Value = gestures.Connect.Value;
CancelAction.Value = gestures.CancelAction.Value;
}
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ Disconnect.Unbind();
+ Connect.Unbind();
+ CancelAction.Unbind();
+ }
}
/// Gestures used by the .
@@ -300,22 +491,51 @@ public void Apply(ConnectionGestures gestures)
Disconnect.Value = gestures.Disconnect.Value;
Selection.Apply(gestures.Selection);
}
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ Split.Unbind();
+ Selection.Unbind();
+ Disconnect.Unbind();
+ }
}
/// Gestures for the .
public class GroupingNodeGestures
{
+ public GroupingNodeGestures()
+ {
+ SwitchMovementMode = ModifierKeys.Shift;
+ ToggleContentSelection = new AnyGesture(new KeyGesture(Key.Space, ModifierKeys.Control), new KeyGesture(Key.Enter, ModifierKeys.Control));
+ }
+
/// The key modifier that will toggle between s.
/// The modifier must be allowed by the gesture.
///
Defaults to .
///
- public ModifierKeys SwitchMovementMode { get; set; } = ModifierKeys.Shift;
+ public ModifierKeys SwitchMovementMode { get; set; }
+
+ /// Gesture to toggle the content selection of the when it is selected.
+ /// Defaults to +.
+ public InputGestureRef ToggleContentSelection { get; }
/// Copies from the specified gestures.
/// The gestures to copy.
public void Apply(GroupingNodeGestures gestures)
{
SwitchMovementMode = gestures.SwitchMovementMode;
+ ToggleContentSelection.Value = gestures.ToggleContentSelection.Value;
+ }
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ ToggleContentSelection.Unbind();
}
}
@@ -324,18 +544,39 @@ public class MinimapGestures
{
public MinimapGestures()
{
+ Pan = new DirectionalNavigationGestures();
DragViewport = new MouseGesture(MouseAction.LeftClick);
+ ResetViewport = new KeyGesture(Key.Home);
CancelAction = new AnyGesture(new MouseGesture(MouseAction.RightClick), new KeyGesture(Key.Escape));
+ ZoomIn = new AnyGesture(new KeyGesture(Key.OemPlus, ModifierKeys.Control), new KeyGesture(Key.Add, ModifierKeys.Control));
+ ZoomOut = new AnyGesture(new KeyGesture(Key.OemMinus, ModifierKeys.Control), new KeyGesture(Key.Subtract, ModifierKeys.Control));
ZoomModifierKey = ModifierKeys.None;
}
+ ///
+ /// Directional gestures used for panning the viewport.
+ ///
+ /// Defaults to +arrow keys.
+ public DirectionalNavigationGestures Pan { get; }
+
/// Gesture to move the viewport inside the .
public InputGestureRef DragViewport { get; }
+ /// Gesture to move the viewport inside the .
+ public InputGestureRef ResetViewport { get; }
+
/// Gesture to cancel the panning operation.
/// Defaults to or .
public InputGestureRef CancelAction { get; }
+ /// Gesture used to zoom in.
+ /// Defaults to +.
+ public InputGestureRef ZoomIn { get; }
+
+ /// Gesture used to zoom out.
+ /// Defaults to +.
+ public InputGestureRef ZoomOut { get; }
+
/// The key modifier required to start zooming by mouse wheel.
/// Defaults to .
public ModifierKeys ZoomModifierKey { get; set; }
@@ -344,10 +585,27 @@ public MinimapGestures()
/// The gestures to copy.
public void Apply(MinimapGestures gestures)
{
+ Pan.Apply(gestures.Pan);
+ ZoomIn.Value = gestures.ZoomIn.Value;
+ ZoomOut.Value = gestures.ZoomOut.Value;
+ ResetViewport.Value = gestures.ResetViewport.Value;
DragViewport.Value = gestures.DragViewport.Value;
CancelAction.Value = gestures.CancelAction.Value;
ZoomModifierKey = gestures.ZoomModifierKey;
}
+
+ ///
+ /// Unbinds all the gestures.
+ ///
+ public void Unbind()
+ {
+ Pan.Unbind();
+ ZoomIn.Unbind();
+ ZoomOut.Unbind();
+ ResetViewport.Unbind();
+ DragViewport.Unbind();
+ CancelAction.Unbind();
+ }
}
/// Gestures for the editor.
@@ -379,5 +637,17 @@ public void Apply(EditorGestures gestures)
GroupingNode.Apply(gestures.GroupingNode);
Minimap.Apply(gestures.Minimap);
}
+
+ ///
+ /// Unbinds all the gestures used by the editor and its controls.
+ ///
+ public void Unbind()
+ {
+ Editor.Unbind();
+ ItemContainer.Unbind();
+ Connector.Unbind();
+ Connection.Unbind();
+ Minimap.Unbind();
+ }
}
}
diff --git a/Nodify/Interactivity/Gestures/KeyComboGesture.cs b/Nodify/Interactivity/Gestures/KeyComboGesture.cs
new file mode 100644
index 00000000..955f528f
--- /dev/null
+++ b/Nodify/Interactivity/Gestures/KeyComboGesture.cs
@@ -0,0 +1,131 @@
+using System.Windows;
+using System.Windows.Input;
+
+namespace Nodify.Interactivity
+{
+ ///
+ /// Represents a keyboard gesture that requires a trigger key to be held down
+ /// before pressing a combo key. For example, press and hold Space, then press Left arrow.
+ ///
+ public class KeyComboGesture : KeyGesture
+ {
+ private static readonly WeakReferenceCollection _allCombos = new WeakReferenceCollection(16);
+
+ private bool _isTriggerDown;
+ private int _comboCounter;
+
+ ///
+ /// Gets a value indicating whether the combo gesture has been performed at least once.
+ ///
+ private bool HasBeenPerformedAtLeastOnce => _comboCounter > 0;
+
+ ///
+ /// Gets or sets the key that must be pressed first to activate this combo gesture.
+ ///
+ public Key TriggerKey { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the combo key can be repeatedly triggered
+ /// without releasing the trigger key.
+ ///
+ public bool AllowRepeatingComboKey { get; set; }
+
+ static KeyComboGesture()
+ {
+ EventManager.RegisterClassHandler(typeof(UIElement), UIElement.PreviewKeyUpEvent, new KeyEventHandler(HandleKeyUp), true);
+ EventManager.RegisterClassHandler(typeof(UIElement), UIElement.LostKeyboardFocusEvent, new KeyboardFocusChangedEventHandler(HandleFocusLost), true);
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified trigger and combo keys.
+ ///
+ /// The key that must be pressed first.
+ /// The combo key pressed while the trigger key is held.
+ public KeyComboGesture(Key triggerKey, Key comboKey) : this(triggerKey, comboKey, ModifierKeys.None, string.Empty)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified trigger and combo keys and modifiers.
+ ///
+ /// The key that must be pressed first.
+ /// The combo key pressed while the trigger key is held.
+ /// Any modifier keys required for the combo key.
+ public KeyComboGesture(Key triggerKey, Key comboKey, ModifierKeys modifiers) : this(triggerKey, comboKey, modifiers, string.Empty)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the specified trigger key,
+ /// combo key, modifiers, and display string.
+ ///
+ /// The key that must be pressed first.
+ /// The combo key pressed while the trigger key is held.
+ /// Any modifier keys required for the combo key.
+ /// The display string representing the gesture.
+ public KeyComboGesture(Key triggerKey, Key comboKey, ModifierKeys modifiers, string displayString) : base(comboKey, modifiers, displayString)
+ {
+ TriggerKey = triggerKey;
+ _allCombos.Add(this);
+ }
+
+ private static void HandleFocusLost(object sender, KeyboardFocusChangedEventArgs e)
+ {
+ foreach (var combo in _allCombos)
+ {
+ combo.Reset();
+ }
+ }
+
+ private static void HandleKeyUp(object sender, KeyEventArgs e)
+ {
+ foreach (var combo in _allCombos)
+ {
+ if (e.Key == combo.TriggerKey)
+ {
+ // We don't want to handle the event if only the trigger key was pressed.
+ if (combo.HasBeenPerformedAtLeastOnce)
+ {
+ e.Handled = true;
+ }
+ combo.Reset();
+ }
+ }
+ }
+
+ private void Reset()
+ {
+ _isTriggerDown = false;
+ _comboCounter = 0;
+ }
+
+ public override bool Matches(object targetElement, InputEventArgs inputEventArgs)
+ {
+ if (inputEventArgs is KeyEventArgs { IsDown: true } keyArgs)
+ {
+ if (keyArgs.Key == TriggerKey)
+ {
+ _isTriggerDown = true;
+ }
+
+ // The combo key only triggers the combo on key down
+ bool matches = _isTriggerDown && base.Matches(targetElement, inputEventArgs);
+ if (!matches)
+ {
+ return false;
+ }
+
+ _comboCounter++;
+
+ if (!AllowRepeatingComboKey)
+ {
+ _isTriggerDown = false;
+ }
+
+ return matches;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Nodify/Interactivity/Gestures/MouseGesture.cs b/Nodify/Interactivity/Gestures/MouseGesture.cs
index 4d9450e7..5a66abcd 100644
--- a/Nodify/Interactivity/Gestures/MouseGesture.cs
+++ b/Nodify/Interactivity/Gestures/MouseGesture.cs
@@ -1,5 +1,4 @@
-using System;
-using System.Linq;
+using System.Linq;
using System.Windows.Input;
namespace Nodify.Interactivity
diff --git a/Nodify/Interactivity/KeyboardNavigation/DirectionalFocusNavigator.cs b/Nodify/Interactivity/KeyboardNavigation/DirectionalFocusNavigator.cs
new file mode 100644
index 00000000..6fd1eaa4
--- /dev/null
+++ b/Nodify/Interactivity/KeyboardNavigation/DirectionalFocusNavigator.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Input;
+
+namespace Nodify.Interactivity
+{
+ internal readonly struct DirectionalFocusNavigator
+ where TElement : UIElement, IKeyboardFocusTarget
+ {
+ private readonly IEnumerable> _availableTargets;
+
+ public DirectionalFocusNavigator(IEnumerable> availableTargets)
+ {
+ _availableTargets = availableTargets;
+ }
+
+ public readonly IKeyboardFocusTarget? FindNextFocusTarget(IKeyboardFocusTarget currentContainer, TraversalRequest request)
+ {
+ var currentContainerBounds = currentContainer.Bounds;
+
+ IEnumerable> candidates = request.FocusNavigationDirection switch
+ {
+ FocusNavigationDirection.Left => _availableTargets.Where(c => c.Bounds.Left < currentContainerBounds.Left),
+ FocusNavigationDirection.Right => _availableTargets.Where(c => c.Bounds.Left > currentContainerBounds.Left),
+ FocusNavigationDirection.Up => _availableTargets.Where(c => c.Bounds.Top < currentContainerBounds.Top),
+ FocusNavigationDirection.Down => _availableTargets.Where(c => c.Bounds.Top > currentContainerBounds.Top),
+ FocusNavigationDirection.Previous => FindCandidatesLinearly(currentContainer, request),
+ FocusNavigationDirection.Next => FindCandidatesLinearly(currentContainer, request),
+ FocusNavigationDirection.First => FindCandidatesLinearly(currentContainer, request),
+ FocusNavigationDirection.Last => FindCandidatesLinearly(currentContainer, request),
+ _ => Array.Empty>()
+ };
+
+ // Wrap focus if no candidates found in the current direction
+ if (!candidates.Any())
+ {
+ candidates = request.FocusNavigationDirection switch
+ {
+ FocusNavigationDirection.Left => _availableTargets.OrderByDescending(c => c.Bounds.Left).Take(1),
+ FocusNavigationDirection.Right => _availableTargets.OrderBy(c => c.Bounds.Left).Take(1),
+ FocusNavigationDirection.Up => _availableTargets.OrderByDescending(c => c.Bounds.Top).Take(1),
+ FocusNavigationDirection.Down => _availableTargets.OrderBy(c => c.Bounds.Top).Take(1),
+ _ => Array.Empty>()
+ };
+
+ request.Wrapped = true;
+ }
+
+ IKeyboardFocusTarget? best = null;
+ double minDistanceSquared = double.MaxValue;
+
+ foreach (var candidate in candidates)
+ {
+ double distanceSquared = (candidate.Bounds.TopLeft - currentContainerBounds.TopLeft).LengthSquared;
+ if (distanceSquared < minDistanceSquared)
+ {
+ minDistanceSquared = distanceSquared;
+ best = candidate;
+ }
+ }
+
+ return best;
+ }
+
+ private IKeyboardFocusTarget[] FindCandidatesLinearly(IKeyboardFocusTarget currentContainer, TraversalRequest request)
+ {
+ var nextTarget = new LinearFocusNavigator(_availableTargets).FindNextFocusTarget(currentContainer, request);
+ return nextTarget is null ? Array.Empty>() : new[] { nextTarget };
+ }
+ }
+}
diff --git a/Nodify/Interactivity/KeyboardNavigation/IKeyboardNavigationLayer.cs b/Nodify/Interactivity/KeyboardNavigation/IKeyboardNavigationLayer.cs
new file mode 100644
index 00000000..0e871a3c
--- /dev/null
+++ b/Nodify/Interactivity/KeyboardNavigation/IKeyboardNavigationLayer.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Input;
+
+namespace Nodify.Interactivity
+{
+ ///
+ /// Represents a unique identifier for a keyboard navigation layer.
+ ///
+ public class KeyboardNavigationLayerId
+ {
+ public static readonly KeyboardNavigationLayerId Nodes = new KeyboardNavigationLayerId();
+ public static readonly KeyboardNavigationLayerId Connections = new KeyboardNavigationLayerId();
+ public static readonly KeyboardNavigationLayerId Decorators = new KeyboardNavigationLayerId();
+ }
+
+ ///
+ /// Represents a group of keyboard navigation layers that can be activated and navigated through.
+ ///
+ public interface IKeyboardNavigationLayerGroup : IReadOnlyCollection
+ {
+ ///
+ /// The current active keyboard navigation layer in the group, if any.
+ ///
+ IKeyboardNavigationLayer? ActiveNavigationLayer { get; }
+
+ ///
+ /// Event that is raised when the active keyboard navigation layer changes.
+ ///
+ event Action? ActiveNavigationLayerChanged;
+
+ ///
+ /// Activates the next keyboard navigation layer in the group, allowing focus to be restored to the last focused element in that layer.
+ ///
+ /// Returns true if the navigation layer was activated, false otherwise.
+ bool ActivateNextNavigationLayer();
+
+ ///
+ /// Activates the previous keyboard navigation layer in the group, allowing focus to be restored to the last focused element in that layer.
+ ///
+ /// Returns true if the navigation layer was activated, false otherwise.
+ bool ActivatePreviousNavigationLayer();
+
+ ///
+ /// Registers a new keyboard navigation layer to the group, allowing it to handle focus movement and restoration.
+ ///
+ /// The navigation layer.
+ ///
+ bool RegisterNavigationLayer(IKeyboardNavigationLayer layer);
+
+ ///
+ /// Removes the specified keyboard navigation layer from the group.
+ ///
+ /// The navigation layer id.
+ /// Returns true if the layer was removed, false otherwise.
+ bool RemoveNavigationLayer(KeyboardNavigationLayerId layerId);
+
+ ///
+ /// Activates the specified keyboard navigation layer, making it the active layer for focus management.
+ ///
+ /// The navigation layer id to activate.
+ /// Returns true if the navigation layer was activated, false otherwise.
+ bool ActivateNavigationLayer(KeyboardNavigationLayerId layerId);
+ }
+
+ ///
+ /// Represents a layer of keyboard navigation that can handle focus movement and restoration.
+ ///
+ public interface IKeyboardNavigationLayer
+ {
+ ///
+ /// Gets the unique identifier for this keyboard navigation layer.
+ ///
+ KeyboardNavigationLayerId Id { get; }
+
+ ///
+ /// Gets the last focused element within this layer, if any.
+ ///
+ IKeyboardFocusTarget? LastFocusedElement { get; }
+
+ ///
+ /// Attempts to move focus within this layer based on the provided traversal request.
+ ///
+ /// The traversal request.
+ /// Returns true if the focus was moved, false otherwise.
+ bool TryMoveFocus(TraversalRequest request);
+
+ ///
+ /// Attempts to restore focus to the last focused element within this layer.
+ ///
+ /// Returns true if the focus was restored, false otherwise.
+ bool TryRestoreFocus();
+
+ ///
+ /// Called when the layer is activated, allowing for any necessary setup or focus management.
+ ///
+ void OnActivated();
+
+ ///
+ /// Called when the layer is deactivated, allowing for any necessary cleanup or focus management.
+ ///
+ void OnDeactivated();
+ }
+
+ ///
+ /// Represents a target for keyboard focus within a specific layer, providing bounds and the associated UI element.
+ ///
+ /// The associated UI element.
+ public interface IKeyboardFocusTarget
+ where TElement : UIElement
+ {
+ ///
+ /// Gets the bounds of the focus target within the layer.
+ ///
+ Rect Bounds { get; }
+
+ ///
+ /// Gets the associated UI element for this focus target.
+ ///
+ TElement Element { get; }
+ }
+}
diff --git a/Nodify/Interactivity/KeyboardNavigation/LinearFocusNavigator.cs b/Nodify/Interactivity/KeyboardNavigation/LinearFocusNavigator.cs
new file mode 100644
index 00000000..09e8486c
--- /dev/null
+++ b/Nodify/Interactivity/KeyboardNavigation/LinearFocusNavigator.cs
@@ -0,0 +1,70 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Input;
+
+namespace Nodify.Interactivity
+{
+ internal readonly struct LinearFocusNavigator
+ where TElement : UIElement, IKeyboardFocusTarget
+ {
+ private enum LinearNavigationDirection
+ {
+ First,
+ Last,
+ Forward,
+ Backward
+ }
+
+ private readonly IEnumerable> _availableTargets;
+
+ public LinearFocusNavigator(IEnumerable> availableTargets)
+ {
+ _availableTargets = availableTargets;
+ }
+
+ public readonly IKeyboardFocusTarget? FindNextFocusTarget(IKeyboardFocusTarget currentContainer, TraversalRequest request)
+ {
+ var direction = IsBackward(request.FocusNavigationDirection) ? LinearNavigationDirection.Backward
+ : IsForward(request.FocusNavigationDirection) ? LinearNavigationDirection.Forward
+ : request.FocusNavigationDirection == FocusNavigationDirection.First ? LinearNavigationDirection.First : LinearNavigationDirection.Last;
+
+ var availableTargets = _availableTargets as List> ?? _availableTargets.ToList();
+ int currentIndex = availableTargets.IndexOf(currentContainer);
+
+ IKeyboardFocusTarget? candidate = direction switch
+ {
+ LinearNavigationDirection.Forward when currentIndex >= 0 && currentIndex + 1 < availableTargets.Count => availableTargets[currentIndex + 1],
+ LinearNavigationDirection.Backward when currentIndex > 0 => availableTargets[currentIndex - 1],
+ LinearNavigationDirection.First when availableTargets.Count > 0 => availableTargets[0],
+ LinearNavigationDirection.Last when availableTargets.Count > 0 => availableTargets[availableTargets.Count - 1],
+ _ => null
+ };
+
+ // Wrap focus if no candidates found in the current direction
+ if (candidate is null)
+ {
+ candidate = direction switch
+ {
+ LinearNavigationDirection.Forward when availableTargets.Count > 0 => availableTargets[0],
+ LinearNavigationDirection.Backward when availableTargets.Count > 0 => availableTargets[availableTargets.Count - 1],
+ _ => null
+ };
+
+ request.Wrapped = candidate != null;
+ }
+
+ return candidate;
+ }
+
+ private static bool IsForward(FocusNavigationDirection dir)
+ {
+ return dir == FocusNavigationDirection.Right || dir == FocusNavigationDirection.Up || dir == FocusNavigationDirection.Next;
+ }
+
+ private static bool IsBackward(FocusNavigationDirection dir)
+ {
+ return dir == FocusNavigationDirection.Left || dir == FocusNavigationDirection.Down || dir == FocusNavigationDirection.Previous;
+ }
+ }
+}
diff --git a/Nodify/Interactivity/KeyboardNavigation/StatefulFocusNavigator.cs b/Nodify/Interactivity/KeyboardNavigation/StatefulFocusNavigator.cs
new file mode 100644
index 00000000..971e9256
--- /dev/null
+++ b/Nodify/Interactivity/KeyboardNavigation/StatefulFocusNavigator.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Windows.Input;
+using System.Windows;
+
+namespace Nodify.Interactivity
+{
+ internal class StatefulFocusNavigator
+ where TElement : UIElement, IKeyboardFocusTarget
+ {
+ public delegate bool FindNextFocusTargetDelegate(TElement? currentElement, TraversalRequest request, out TElement? elementToFocus);
+
+ private readonly WeakReference _previousFocusedElement = new WeakReference(null);
+ private readonly WeakReference _lastFocusedElement = new WeakReference(null);
+ private FocusNavigationDirection? _previousFocusNavigationDirection;
+
+ private readonly Action> _onFocus;
+
+ public TElement? LastFocusedElement => _lastFocusedElement.TryGetTarget(out var target) ? target : null;
+
+ public StatefulFocusNavigator(Action> onFocus)
+ {
+ _onFocus = onFocus;
+ }
+
+ public bool TryMoveFocus(TraversalRequest request, FindNextFocusTargetDelegate findNext)
+ {
+ var currentTarget = Keyboard.FocusedElement as TElement;
+
+ // If the request is in the opposite direction of the last focus navigation, try to restore the previous focused container
+ if (_previousFocusedElement.TryGetTarget(out var prevTarget)
+ && _previousFocusNavigationDirection.HasValue
+ && request.FocusNavigationDirection.IsOppositeOf(_previousFocusNavigationDirection.Value)
+ && prevTarget!.Focus())
+ {
+ _previousFocusNavigationDirection = request.FocusNavigationDirection;
+ _previousFocusedElement.SetTarget(currentTarget);
+ _lastFocusedElement.SetTarget(prevTarget);
+
+ _onFocus(prevTarget);
+ return true;
+ }
+ else if (findNext(currentTarget, request, out var nextTarget) && nextTarget!.Element.Focus())
+ {
+ _previousFocusNavigationDirection = request.FocusNavigationDirection;
+ _previousFocusedElement.SetTarget(currentTarget);
+ _lastFocusedElement.SetTarget(nextTarget);
+
+ _onFocus(nextTarget);
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool TryRestoreFocus()
+ {
+ if (_lastFocusedElement.TryGetTarget(out var lastTarget))
+ {
+ if (lastTarget!.IsKeyboardFocused)
+ {
+ return true;
+ }
+
+ if (lastTarget.Focus())
+ {
+ _onFocus.Invoke(lastTarget);
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Nodify/Minimap/Minimap.cs b/Nodify/Minimap/Minimap.cs
index 86fcbc6b..a0b91d51 100644
--- a/Nodify/Minimap/Minimap.cs
+++ b/Nodify/Minimap/Minimap.cs
@@ -116,12 +116,21 @@ public event ZoomEventHandler Zoom
///
public static bool AllowPanningCancellation { get; set; } = true;
+ ///
+ /// Defines the distance to pan when using directional input (such as arrow keys).
+ ///
+ public static double NavigationStepSize { get; set; } = 50d;
+
private Point _initialViewportLocation;
static Minimap()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(typeof(Minimap)));
- ClipToBoundsProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(BoxValue.True));
+ FocusableProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(BoxValue.True));
+
+ KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.ControlTabNavigationProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
+ KeyboardNavigation.DirectionalNavigationProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(KeyboardNavigationMode.None));
}
public Minimap()
@@ -218,7 +227,7 @@ public void BeginPanning()
/// The initial location where panning starts, in graph space coordinates.
public void BeginPanning(Point location)
{
- if (IsPanning)
+ if (IsPanning || IsReadOnly)
{
return;
}
@@ -238,6 +247,23 @@ public void UpdatePanning(Point location)
SetViewportLocation(location);
}
+ ///
+ /// Pans the viewport by the specified amount.
+ ///
+ /// The amount to pan the viewport.
+ ///
+ /// This method adjusts the current incrementally based on the provided amount.
+ ///
+ public void UpdatePanning(Vector amount)
+ {
+ if (IsReadOnly)
+ {
+ return;
+ }
+
+ ViewportLocation -= amount;
+ }
+
///
/// Ends the current panning operation, retaining the current .
///
@@ -273,8 +299,26 @@ public void CancelPanning()
}
}
+ protected void SetViewportLocation(Point location)
+ {
+ var position = location - new Vector(ViewportSize.Width / 2, ViewportSize.Height / 2) + (Vector)Extent.Location;
+
+ if (MaxViewportOffset.Width != 0 || MaxViewportOffset.Height != 0)
+ {
+ double maxRight = ResizeToViewport ? ItemsExtent.Right : Math.Max(ItemsExtent.Right, ItemsExtent.Left + ViewportSize.Width);
+ double maxBottom = ResizeToViewport ? ItemsExtent.Bottom : Math.Max(ItemsExtent.Bottom, ItemsExtent.Top + ViewportSize.Height);
+
+ position.X = position.X.Clamp(ItemsExtent.Left - ViewportSize.Width / 2 - MaxViewportOffset.Width, maxRight - ViewportSize.Width / 2 + MaxViewportOffset.Width);
+ position.Y = position.Y.Clamp(ItemsExtent.Top - ViewportSize.Height / 2 - MaxViewportOffset.Height, maxBottom - ViewportSize.Height / 2 + MaxViewportOffset.Height);
+ }
+
+ ViewportLocation = position;
+ }
+
#endregion
+ #region Zooming
+
///
/// Zoom at the specified location in graph space coordinates.
///
@@ -282,6 +326,11 @@ public void CancelPanning()
/// The location to focus when zooming.
public void ZoomAtPosition(double zoom, Point location)
{
+ if (IsReadOnly)
+ {
+ return;
+ }
+
if (!ResizeToViewport)
{
SetViewportLocation(location);
@@ -296,22 +345,40 @@ public void ZoomAtPosition(double zoom, Point location)
RaiseEvent(args);
}
- protected void SetViewportLocation(Point location)
- {
- var position = location - new Vector(ViewportSize.Width / 2, ViewportSize.Height / 2) + (Vector)Extent.Location;
+ ///
+ /// Zoom in at the viewport's center.
+ ///
+ public void ZoomIn() => SetZoom(Math.Pow(2.0, 120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine));
- if (MaxViewportOffset.Width != 0 || MaxViewportOffset.Height != 0)
- {
- double maxRight = ResizeToViewport ? ItemsExtent.Right : Math.Max(ItemsExtent.Right, ItemsExtent.Left + ViewportSize.Width);
- double maxBottom = ResizeToViewport ? ItemsExtent.Bottom : Math.Max(ItemsExtent.Bottom, ItemsExtent.Top + ViewportSize.Height);
+ ///
+ /// Zoom out at the viewport's center.
+ ///
+ public void ZoomOut() => SetZoom(Math.Pow(2.0, -120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine));
- position.X = position.X.Clamp(ItemsExtent.Left - ViewportSize.Width / 2 - MaxViewportOffset.Width, maxRight - ViewportSize.Width / 2 + MaxViewportOffset.Width);
- position.Y = position.Y.Clamp(ItemsExtent.Top - ViewportSize.Height / 2 - MaxViewportOffset.Height, maxBottom - ViewportSize.Height / 2 + MaxViewportOffset.Height);
- }
+ public void ResetViewport()
+ {
+ SetCurrentValue(ViewportLocationProperty, new Point(0, 0));
+ var args = new ZoomEventArgs(1d, new Point(ViewportSize.Width / 2, ViewportSize.Height / 2))
+ {
+ RoutedEvent = ZoomEvent,
+ Source = this
+ };
+ RaiseEvent(args);
+ }
- ViewportLocation = position;
+ private void SetZoom(double zoom)
+ {
+ var viewportLocation = ViewportLocation + (Vector)ViewportSize / 2;
+ var args = new ZoomEventArgs(zoom, viewportLocation)
+ {
+ RoutedEvent = ZoomEvent,
+ Source = this
+ };
+ RaiseEvent(args);
}
+ #endregion
+
///
/// Translates the event location to graph space coordinates (relative to the ).
///
diff --git a/Nodify/Minimap/MinimapItem.cs b/Nodify/Minimap/MinimapItem.cs
index d792a83b..f60de8c9 100644
--- a/Nodify/Minimap/MinimapItem.cs
+++ b/Nodify/Minimap/MinimapItem.cs
@@ -5,6 +5,11 @@ namespace Nodify
{
public class MinimapItem : ContentControl
{
+ static MinimapItem()
+ {
+ FocusableProperty.OverrideMetadata(typeof(MinimapItem), new FrameworkPropertyMetadata(BoxValue.False));
+ }
+
public static readonly DependencyProperty LocationProperty = ItemContainer.LocationProperty.AddOwner(typeof(MinimapItem), new FrameworkPropertyMetadata(BoxValue.Point, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsParentMeasure));
///
diff --git a/Nodify/Minimap/States/KeyboardNavigation.cs b/Nodify/Minimap/States/KeyboardNavigation.cs
new file mode 100644
index 00000000..77953aca
--- /dev/null
+++ b/Nodify/Minimap/States/KeyboardNavigation.cs
@@ -0,0 +1,35 @@
+using System.Windows;
+using System.Windows.Input;
+
+namespace Nodify.Interactivity
+{
+ public static partial class MinimapState
+ {
+ public class KeyboardNavigation : InputElementState
+ {
+ public KeyboardNavigation(Minimap element) : base(element)
+ {
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ if (Element.IsKeyboardFocused)
+ {
+ var gestures = EditorGestures.Mappings.Minimap;
+
+ if (gestures.Pan.TryGetNavigationDirection(e, out var panDirection))
+ {
+ var panning = new Vector(-panDirection.X * Minimap.NavigationStepSize, panDirection.Y * Minimap.NavigationStepSize);
+ Element.UpdatePanning(panning);
+ e.Handled = true;
+ }
+ else if (gestures.ResetViewport.Matches(e.Source, e))
+ {
+ Element.ResetViewport();
+ e.Handled = true;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Nodify/Minimap/States/MinimapState.cs b/Nodify/Minimap/States/MinimapState.cs
index 5af3a270..15bbc14b 100644
--- a/Nodify/Minimap/States/MinimapState.cs
+++ b/Nodify/Minimap/States/MinimapState.cs
@@ -11,6 +11,7 @@ internal static void RegisterDefaultHandlers()
{
InputProcessor.Shared.RegisterHandlerFactory(elem => new Panning(elem));
InputProcessor.Shared.RegisterHandlerFactory(elem => new Zooming(elem));
+ InputProcessor.Shared.RegisterHandlerFactory(elem => new KeyboardNavigation(elem));
}
}
}
diff --git a/Nodify/Minimap/States/Zooming.cs b/Nodify/Minimap/States/Zooming.cs
index 75d1696e..8607fa3f 100644
--- a/Nodify/Minimap/States/Zooming.cs
+++ b/Nodify/Minimap/States/Zooming.cs
@@ -28,6 +28,25 @@ protected override void OnMouseWheel(MouseWheelEventArgs e)
e.Handled = true;
}
}
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ var gestures = EditorGestures.Mappings.Minimap;
+
+ if (!Element.IsReadOnly)
+ {
+ if (gestures.ZoomIn.Matches(e.Source, e))
+ {
+ Element.ZoomIn();
+ e.Handled = true;
+ }
+ else if (gestures.ZoomOut.Matches(e.Source, e))
+ {
+ Element.ZoomOut();
+ e.Handled = true;
+ }
+ }
+ }
}
}
}
diff --git a/Nodify/Nodes/GroupingNode.cs b/Nodify/Nodes/GroupingNode.cs
index 5151cd24..5197e8ed 100644
--- a/Nodify/Nodes/GroupingNode.cs
+++ b/Nodify/Nodes/GroupingNode.cs
@@ -1,6 +1,7 @@
using Nodify.Events;
using Nodify.Interactivity;
using System;
+using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
@@ -174,6 +175,7 @@ public ICommand? ResizeStartedCommand
static GroupingNode()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(GroupingNode), new FrameworkPropertyMetadata(typeof(GroupingNode)));
+ FocusableProperty.OverrideMetadata(typeof(GroupingNode), new FrameworkPropertyMetadata(BoxValue.False));
Panel.ZIndexProperty.OverrideMetadata(typeof(GroupingNode), new FrameworkPropertyMetadata(-1, OnZIndexPropertyChanged));
}
@@ -230,36 +232,38 @@ private void OnHeaderMouseDown(object sender, MouseButtonEventArgs e)
MovementMode = MovementMode == GroupingMovementMode.Group ? GroupingMovementMode.Self : GroupingMovementMode.Group;
}
+ var groupBounds = new Rect(Container.Location, RenderSize);
+
// Select the content and move with it
if (gestures.Selection.Append.Matches(e.Source, e))
{
- Editor.SelectArea(new Rect(Container.Location, RenderSize), append: true, fit: true);
+ Editor.SelectArea(groupBounds, append: true, fit: true);
}
else if (gestures.Selection.Remove.Matches(e.Source, e))
{
- Editor.UnselectArea(new Rect(Container.Location, RenderSize), fit: true);
+ Editor.UnselectArea(groupBounds, fit: true);
}
else if (gestures.Selection.Invert.Matches(e.Source, e))
{
if (Container.IsSelected)
{
- Editor.UnselectArea(new Rect(Container.Location, RenderSize), fit: true);
+ Editor.UnselectArea(groupBounds, fit: true);
Container.IsSelected = true;
}
else
{
- Editor.SelectArea(new Rect(Container.Location, RenderSize), append: true, fit: true);
+ Editor.SelectArea(groupBounds, append: true, fit: true);
}
}
else if (gestures.Selection.Replace.Matches(e.Source, e) || EditorGestures.Mappings.ItemContainer.Drag.Matches(e.Source, e))
{
- Editor.SelectArea(new Rect(Container.Location, RenderSize), append: Container.IsSelected, fit: true);
+ Editor.SelectArea(groupBounds, append: Container.IsSelected, fit: true);
}
// Deselect content
if (MovementMode == GroupingMovementMode.Self)
{
- Editor.UnselectArea(new Rect(Container.Location, RenderSize), fit: true);
+ Editor.UnselectArea(groupBounds, fit: true);
Container.IsSelected = true;
}
@@ -268,6 +272,29 @@ private void OnHeaderMouseDown(object sender, MouseButtonEventArgs e)
}
}
+ ///
+ /// Toggles the selection of nodes inside this group.
+ /// If any contained nodes are selected, all will be unselected.
+ /// If none are selected, all will be selected.
+ ///
+ public void ToggleContentSelection()
+ {
+ if (Editor != null && Container != null)
+ {
+ var groupBounds = new Rect(Container.Location, RenderSize);
+ bool hasSelection = Editor.SelectedContainers.Any(x => x != Container && groupBounds.Contains(x.Bounds));
+ if (hasSelection)
+ {
+ Editor.UnselectArea(groupBounds, fit: true);
+ Container.IsSelected = true;
+ }
+ else
+ {
+ Editor.SelectArea(groupBounds, append: true, fit: true);
+ }
+ }
+ }
+
///
public override void OnApplyTemplate()
{
diff --git a/Nodify/Nodes/KnotNode.cs b/Nodify/Nodes/KnotNode.cs
index f26e9e9a..6851ca44 100644
--- a/Nodify/Nodes/KnotNode.cs
+++ b/Nodify/Nodes/KnotNode.cs
@@ -11,6 +11,7 @@ public class KnotNode : ContentControl
static KnotNode()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(KnotNode), new FrameworkPropertyMetadata(typeof(KnotNode)));
+ FocusableProperty.OverrideMetadata(typeof(KnotNode), new FrameworkPropertyMetadata(BoxValue.False));
}
}
}
diff --git a/Nodify/Nodes/Node.cs b/Nodify/Nodes/Node.cs
index 7de14c0f..f95d8693 100644
--- a/Nodify/Nodes/Node.cs
+++ b/Nodify/Nodes/Node.cs
@@ -169,6 +169,7 @@ private static void OnFooterChanged(DependencyObject d, DependencyPropertyChange
static Node()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Node), new FrameworkPropertyMetadata(typeof(Node)));
+ FocusableProperty.OverrideMetadata(typeof(Node), new FrameworkPropertyMetadata(BoxValue.False));
}
public Node()
diff --git a/Nodify/Nodes/StateNode.cs b/Nodify/Nodes/StateNode.cs
index 5fd3587b..04bce7d5 100644
--- a/Nodify/Nodes/StateNode.cs
+++ b/Nodify/Nodes/StateNode.cs
@@ -66,6 +66,7 @@ public CornerRadius CornerRadius
static StateNode()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(StateNode), new FrameworkPropertyMetadata(typeof(StateNode)));
+ FocusableProperty.OverrideMetadata(typeof(StateNode), new FrameworkPropertyMetadata(BoxValue.False));
}
///
diff --git a/Nodify/Themes/Brushes.xaml b/Nodify/Themes/Brushes.xaml
index a695ec0f..5a1c15f3 100644
--- a/Nodify/Themes/Brushes.xaml
+++ b/Nodify/Themes/Brushes.xaml
@@ -1,6 +1,18 @@
+ xmlns:o="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
+ xmlns:local="clr-namespace:Nodify">
+
+
+
+
+
+
+
+
diff --git a/Nodify/Themes/FocusVisual.xaml b/Nodify/Themes/FocusVisual.xaml
new file mode 100644
index 00000000..0f189823
--- /dev/null
+++ b/Nodify/Themes/FocusVisual.xaml
@@ -0,0 +1,77 @@
+
+
+ #FD5618
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Nodify/Themes/Styles/DecoratorContainer.xaml b/Nodify/Themes/Styles/DecoratorContainer.xaml
index 6ca87de7..78390c7d 100644
--- a/Nodify/Themes/Styles/DecoratorContainer.xaml
+++ b/Nodify/Themes/Styles/DecoratorContainer.xaml
@@ -5,8 +5,6 @@