Skip to content
Merged
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
> - Renamed UnselectAllConnection to UnselectAllConnections in NodifyEditor
> - Removed DragStarted, DragDelta and DragCompleted routed events from ItemContainer
> - Replaced the System.Windows.Input.MouseGesture with Nodify.MouseGesture
> - Removed State, GetInitialState, PushState, PopState and PopAllStates from NodifyEditor and ItemContainer
> - Replaced EditorState and ContainerState with InputElementState
> - Moved AllowCuttingCancellation from CuttingLine to NodifyEditor
> - Moved AllowDraggingCancellation from ItemContainer to NodifyEditor
> - Features:
Expand All @@ -19,18 +21,25 @@
> - Added Select, BeginSelecting, UpdateSelection, EndSelecting, CancelSelecting and AllowSelectionCancellation to NodifyEditor
> - Added IsDragging, BeginDragging, UpdateDragging, EndDragging and CancelDragging to NodifyEditor
> - Added AlignSelection and AlignContainers methods to NodifyEditor
> - Added HasCustomContextMenu dependency property to NodifyEditor and ItemContainer
> - Added HasCustomContextMenu dependency property to NodifyEditor, ItemContainer and Connector
> - Added Select, BeginDragging, UpdateDragging, EndDragging and CancelDragging to ItemContainer
> - Added PreserveSelectionOnRightClick configuration field to ItemContainer
> - Added State, GetInitialState, PushState, PopState and PopAllStates to Connector
> - Added BeginConnecting, UpdatePendingConnection, EndConnecting, CancelConnecting and RemoveConnections methods to Connector
> - Added a custom MouseGesture with support for key combinations
> - Added InputProcessor to NodifyEditor, ItemContainer and Connector, enabling the extension of controls with custom states
> - Added DragState to simplify creating click-and-drag operations, with support for initiating and completing them using the keyboard
> - Added InputElementStateStack to manage transitions between states in UI elements
> - Move the viewport to the mouse position when zooming on the Minimap if ResizeToViewport is false
> - Bugfixes:
> - Fixed an issue where the ItemContainer was selected by releasing the mouse button on it, even when the mouse was not captured
> - Fixed an issue where the ItemContainer could open its context menu even when it was not selected
> - Fixed an issue where the Home button caused the editor to fail to display items when contained within a ScrollViewer
> - Fixed an issue where connector optimization did not work when SelectedItems was not data-bound
> - Fixed EditorCommands.Align to perform a single arrange invalidation instead of one for each aligned container
> - Fixed an issue where controls would capture the mouse unnecessarily; they now capture it only in response to a defined gesture
> - Fixed an issue where the minimap could update the viewport without having the mouse captured
> - Fixed ItemContainer.Select and NodifyEditor.SelectArea to clear the existing selection and select the containers within the same transaction
> - Fixed an issue where editor operations failed to cancel upon losing mouse capture

#### **Version 6.6.0**

Expand Down
8 changes: 4 additions & 4 deletions Examples/Nodify.Calculator/EditorView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,25 @@ public EditorView()
{
InitializeComponent();

EventManager.RegisterClassHandler(typeof(NodifyEditor), MouseLeftButtonDownEvent, new MouseButtonEventHandler(CloseOperationsMenu));
EventManager.RegisterClassHandler(typeof(NodifyEditor), MouseLeftButtonDownEvent, new MouseButtonEventHandler(CloseOperationsMenu), true);
EventManager.RegisterClassHandler(typeof(NodifyEditor), MouseRightButtonUpEvent, new MouseButtonEventHandler(OpenOperationsMenu));
}

private void OpenOperationsMenu(object sender, MouseButtonEventArgs e)
{
if (!e.Handled && e.OriginalSource is NodifyEditor editor && editor.DataContext is CalculatorViewModel calculator)
if (e.OriginalSource is NodifyEditor editor && editor.DataContext is CalculatorViewModel calculator)
{
e.Handled = true;
calculator.OperationsMenu.OpenAt(editor.MouseLocation);
}
}

private void CloseOperationsMenu(object sender, RoutedEventArgs e)
private void CloseOperationsMenu(object sender, MouseButtonEventArgs e)
{
ItemContainer? itemContainer = sender as ItemContainer;
NodifyEditor? editor = sender as NodifyEditor ?? itemContainer?.Editor;

if (!e.Handled && editor?.DataContext is CalculatorViewModel calculator)
if (editor?.DataContext is CalculatorViewModel calculator)
{
calculator.OperationsMenu.Close();
}
Expand Down
1 change: 1 addition & 0 deletions Examples/Nodify.Calculator/OperationsMenuView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<Button Content="{Binding Title}"
Command="{Binding DataContext.CreateOperationCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
ClickMode="Press"
Background="Transparent"
BorderBrush="Transparent"
Foreground="{DynamicResource ForegroundBrush}"
Expand Down
12 changes: 10 additions & 2 deletions Examples/Nodify.StateMachine/StateMachineViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ public StateMachineViewModel()
Conditions = new NodifyObservableCollection<BlackboardItemReferenceViewModel>(BlackboardDescriptor.GetAvailableItems<IBlackboardCondition>())
};

Transitions.WhenAdded(c => c.Source.Transitions.Add(c.Target))
.WhenRemoved(c => c.Source.Transitions.Remove(c.Target))
Transitions.WhenAdded(c =>
{
c.Source.Transitions.Add(c.Target);
c.Target.Transitions.Add(c.Source);
})
.WhenRemoved(c =>
{
c.Source.Transitions.Remove(c.Target);
c.Target.Transitions.Remove(c.Source);
})
.WhenCleared(c => c.ForEach(i =>
{
i.Source.Transitions.Clear();
Expand Down
10 changes: 0 additions & 10 deletions Nodify/Connections/BaseConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@
{
#region Dependency Properties

public static readonly DependencyProperty SourceProperty = DependencyProperty.Register(nameof(Source), typeof(Point), typeof(BaseConnection), new FrameworkPropertyMetadata(BoxValue.Point, FrameworkPropertyMetadataOptions.AffectsRender));

Check warning on line 114 in Nodify/Connections/BaseConnection.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'BaseConnection.SourceProperty'
public static readonly DependencyProperty TargetProperty = DependencyProperty.Register(nameof(Target), typeof(Point), typeof(BaseConnection), new FrameworkPropertyMetadata(BoxValue.Point, FrameworkPropertyMetadataOptions.AffectsRender));

Check warning on line 115 in Nodify/Connections/BaseConnection.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'BaseConnection.TargetProperty'
public static readonly DependencyProperty SourceOffsetProperty = DependencyProperty.Register(nameof(SourceOffset), typeof(Size), typeof(BaseConnection), new FrameworkPropertyMetadata(BoxValue.ConnectionOffset, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty TargetOffsetProperty = DependencyProperty.Register(nameof(TargetOffset), typeof(Size), typeof(BaseConnection), new FrameworkPropertyMetadata(BoxValue.ConnectionOffset, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty SourceOffsetModeProperty = DependencyProperty.Register(nameof(SourceOffsetMode), typeof(ConnectionOffsetMode), typeof(BaseConnection), new FrameworkPropertyMetadata(ConnectionOffsetMode.Static, FrameworkPropertyMetadataOptions.AffectsRender));
Expand Down Expand Up @@ -774,8 +774,6 @@
{
Focus();

this.CaptureMouseSafe();

EditorGestures.ConnectionGestures gestures = EditorGestures.Mappings.Connection;
if (gestures.Split.Matches(e.Source, e))
{
Expand Down Expand Up @@ -827,14 +825,6 @@
}
}

protected override void OnMouseUp(MouseButtonEventArgs e)
{
if (IsMouseCaptured)
{
ReleaseMouseCapture();
}
}

private Pen GetOutlinePen()
{
return _outlinePen ??= new Pen(OutlineBrush, StrokeThickness + OutlineThickness * 2d);
Expand Down
151 changes: 70 additions & 81 deletions Nodify/Connections/Connector.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
Expand Down Expand Up @@ -61,6 +60,7 @@ public event ConnectorEventHandler Disconnect
public static readonly DependencyProperty DisconnectCommandProperty = DependencyProperty.Register(nameof(DisconnectCommand), typeof(ICommand), typeof(Connector));
private static readonly DependencyPropertyKey IsPendingConnectionPropertyKey = DependencyProperty.RegisterReadOnly(nameof(IsPendingConnection), typeof(bool), typeof(Connector), new FrameworkPropertyMetadata(BoxValue.False));
public static readonly DependencyProperty IsPendingConnectionProperty = IsPendingConnectionPropertyKey.DependencyProperty;
public static readonly DependencyProperty HasCustomContextMenuProperty = NodifyEditor.HasCustomContextMenuProperty.AddOwner(typeof(Connector));

/// <summary>
/// Gets the location in graph space coordinates where <see cref="Connection"/>s can be attached to.
Expand Down Expand Up @@ -100,18 +100,22 @@ public ICommand? DisconnectCommand
set => SetValue(DisconnectCommandProperty, value);
}

#endregion

static Connector()
/// <summary>
/// Gets or sets a value indicating whether the connector uses a custom context menu.
/// </summary>
/// <remarks>When set to true, the connector handles the right-click event for specific operations.</remarks>
public bool HasCustomContextMenu
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Connector), new FrameworkPropertyMetadata(typeof(Connector)));
FocusableProperty.OverrideMetadata(typeof(Connector), new FrameworkPropertyMetadata(BoxValue.True));
get => (bool)GetValue(HasCustomContextMenuProperty);
set => SetValue(HasCustomContextMenuProperty, value);
}

public Connector()
{
_states.Push(GetInitialState());
}
/// <summary>
/// Gets a value indicating whether the connector has a context menu.
/// </summary>
public bool HasContextMenu => ContextMenu != null || HasCustomContextMenu;

#endregion

#region Fields

Expand Down Expand Up @@ -162,6 +166,19 @@ public Connector()

#endregion

static Connector()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Connector), new FrameworkPropertyMetadata(typeof(Connector)));
FocusableProperty.OverrideMetadata(typeof(Connector), new FrameworkPropertyMetadata(BoxValue.True));
}

public Connector()
{
InputProcessor.AddHandler(new ConnectorDisconnectState(this));
InputProcessor.AddHandler(new ConnectorConnectingState(this));
InputProcessor.AddHandler(new ConnectorDefaultState(this));
}

/// <inheritdoc />
public override void OnApplyTemplate()
{
Expand Down Expand Up @@ -314,68 +331,18 @@ public void UpdateAnchor()

#endregion

#region State Handling

private readonly Stack<ConnectorState> _states = new Stack<ConnectorState>();

/// <summary>The current state of the connector.</summary>
public ConnectorState State => _states.Peek();

/// <summary>Creates the initial state of the connector.</summary>
/// <returns>The initial state.</returns>
protected virtual ConnectorState GetInitialState()
=> new ConnectorDefaultState(this);

/// <summary>Pushes the given state to the stack.</summary>
/// <param name="state">The new state of the connector.</param>
/// <remarks>Calls <see cref="ConnectorState.Enter"/> on the new state.</remarks>
public void PushState(ConnectorState state)
{
var prev = State;
_states.Push(state);
state.Enter(prev);
}

/// <summary>Pops the current <see cref="State"/> from the stack.</summary>
/// <remarks>It doesn't pop the initial state. (see <see cref="GetInitialState"/>)
/// <br />Calls <see cref="ConnectorState.Exit"/> on the current state.
/// <br />Calls <see cref="ConnectorState.ReEnter"/> on the previous state.
/// </remarks>
public void PopState()
{
// Never remove the default state
if (_states.Count > 1)
{
ConnectorState prev = _states.Pop();
prev.Exit();
State.ReEnter(prev);
}
}
#region Gesture Handling

/// <summary>Pops all states from the connector.</summary>
/// <remarks>It doesn't pop the initial state. (see <see cref="GetInitialState"/>)</remarks>
public void PopAllStates()
{
while (_states.Count > 1)
{
PopState();
}
}
protected InputProcessor InputProcessor { get; } = new InputProcessor { ProcessHandledEvents = true };

/// <inheritdoc />
protected override void OnMouseDown(MouseButtonEventArgs e)
{
Focus();

this.CaptureMouseSafe();

State.HandleMouseDown(e);
}
=> InputProcessor.Process(e);

/// <inheritdoc />
protected override void OnMouseUp(MouseButtonEventArgs e)
{
State.HandleMouseUp(e);
InputProcessor.Process(e);

// Release the mouse capture if all the mouse buttons are released and there's no sticky connection pending
if (!IsPendingConnection && IsMouseCaptured && e.RightButton == MouseButtonState.Released && e.LeftButton == MouseButtonState.Released && e.MiddleButton == MouseButtonState.Released)
Expand All @@ -386,21 +353,38 @@ protected override void OnMouseUp(MouseButtonEventArgs e)

/// <inheritdoc />
protected override void OnMouseMove(MouseEventArgs e)
=> State.HandleMouseMove(e);
=> InputProcessor.Process(e);

/// <inheritdoc />
protected override void OnMouseWheel(MouseWheelEventArgs e)
=> State.HandleMouseWheel(e);
=> InputProcessor.Process(e);

/// <inheritdoc />
protected override void OnLostMouseCapture(MouseEventArgs e)
=> PopAllStates();
=> InputProcessor.Process(e);

/// <inheritdoc />
protected override void OnKeyUp(KeyEventArgs e)
=> State.HandleKeyUp(e);
{
InputProcessor.Process(e);

if (!IsPendingConnection && IsMouseCaptured)
{
ReleaseMouseCapture();
}
}

/// <inheritdoc />
protected override void OnKeyDown(KeyEventArgs e)
=> State.HandleKeyDown(e);
{
InputProcessor.Process(e);

// Release the mouse capture if all the mouse buttons are released and there's no sticky connection pending
if (!IsPendingConnection && IsMouseCaptured && Mouse.RightButton == MouseButtonState.Released && Mouse.LeftButton == MouseButtonState.Released && Mouse.MiddleButton == MouseButtonState.Released)
{
ReleaseMouseCapture();
}
}

#endregion

Expand Down Expand Up @@ -468,26 +452,31 @@ public void UpdatePendingConnection(Point position)
}

/// <summary>
/// Cancels the current pending connection without completing it.
/// Cancels the current pending connection without completing it if <see cref="AllowPendingConnectionCancellation"/> is true.
/// Otherwise, it completes the pending connection by calling <see cref="EndConnecting()"/>.
/// </summary>
/// <remarks>This method has no effect if there's no pending connection.</remarks>
public void CancelConnecting()
{
if (!IsPendingConnection)
if (!AllowPendingConnectionCancellation)
{
EndConnecting();
return;
}

var args = new PendingConnectionEventArgs(DataContext)
if (IsPendingConnection)
{
RoutedEvent = PendingConnectionCompletedEvent,
Anchor = Anchor,
Source = this,
Canceled = true
};
RaiseEvent(args);

IsPendingConnection = false;
var args = new PendingConnectionEventArgs(DataContext)
{
RoutedEvent = PendingConnectionCompletedEvent,
Anchor = Anchor,
Source = this,
Canceled = true
};
RaiseEvent(args);

IsPendingConnection = false;
}
}

/// <summary>
Expand Down
Loading
Loading