Add project files.

This commit is contained in:
Ankitkumar Satapara
2026-04-17 22:31:58 +05:30
commit 21aaef6776
473 changed files with 50152 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Nodify
{
/// <summary>
/// Represents a line that is controlled by an angle.
/// </summary>
public class CircuitConnection : LineConnection
{
protected const double Degrees = Math.PI / 180.0d;
public static readonly DependencyProperty AngleProperty = DependencyProperty.Register(nameof(Angle), typeof(double), typeof(LineConnection), new FrameworkPropertyMetadata(BoxValue.Double45, FrameworkPropertyMetadataOptions.AffectsRender));
/// <summary>
/// The angle of the connection in degrees.
/// </summary>
public double Angle
{
get => (double)GetValue(AngleProperty);
set => SetValue(AngleProperty, value);
}
static CircuitConnection()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(CircuitConnection), new FrameworkPropertyMetadata(typeof(CircuitConnection)));
NodifyEditor.CuttingConnectionTypes.Add(typeof(CircuitConnection));
}
protected override ((Point ArrowStartSource, Point ArrowStartTarget), (Point ArrowEndSource, Point ArrowEndTarget)) DrawLineGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1, p2) = GetLinePoints(source, target);
context.BeginFigure(source, false, false);
if (CornerRadius > 0)
{
AddSmoothCorner(context, source, p0, p1, CornerRadius);
AddSmoothCorner(context, p0, p1, p2, CornerRadius);
AddSmoothCorner(context, p1, p2, target, CornerRadius);
}
else
{
context.LineTo(p0, true, true);
context.LineTo(p1, true, true);
context.LineTo(p2, true, true);
}
context.LineTo(target, true, true);
if (Spacing < 1d)
{
return ((p1, source), (p1, target));
}
return ((p0, source), (p1, target));
}
protected override Point GetTextPosition(FormattedText text, Point source, Point target)
{
var (p0, p1, p2) = GetLinePoints(source, target);
Vector deltaSource = p1 - p0;
Vector deltaTarget = p2 - p1;
if (deltaSource.LengthSquared > deltaTarget.LengthSquared)
{
return new Point((p0.X + p1.X - text.Width) / 2, (p0.Y + p1.Y - text.Height) / 2);
}
return new Point((p2.X + p1.X - text.Width) / 2, (p2.Y + p1.Y - text.Height) / 2);
}
protected override void DrawDirectionalArrowsGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1, p2) = GetLinePoints(source, target);
double spacing = 1d / (DirectionalArrowsCount + 1);
for (int i = 1; i <= DirectionalArrowsCount; i++)
{
double t = (spacing * i + DirectionalArrowsOffset).WrapToRange(0d, 1d);
var (segment, to) = InterpolateLine(p0, p1, p2, t);
var direction = segment.SegmentStart - segment.SegmentEnd;
DrawDirectionalArrowheadGeometry(context, direction, to);
}
}
private (Point P0, Point P1, Point P2) GetLinePoints(in Point source, in Point target)
{
double direction = Direction == ConnectionDirection.Forward ? 1d : -1d;
var spacing = new Vector(Spacing * direction, 0d);
var spacingVertical = new Vector(spacing.Y, spacing.X);
var arrowOffset = new Vector(ArrowSize.Width * direction, 0d);
if (TargetOrientation == Orientation.Vertical)
{
(arrowOffset.X, arrowOffset.Y) = (arrowOffset.Y, arrowOffset.X);
}
Point endPoint = Spacing > 0 ? target - arrowOffset : target;
Point p1 = source + (SourceOrientation == Orientation.Vertical ? spacingVertical : spacing);
Point p3 = endPoint - (TargetOrientation == Orientation.Vertical ? spacingVertical : spacing);
Point p2 = GetControlPoint(p1, p3);
return (p1, p2, p3);
}
private Point GetControlPoint(in Point source, in Point target)
{
Vector delta = target - source;
double tangent = Math.Tan(Angle * Degrees);
double dx = Math.Abs(delta.X);
double dy = Math.Abs(delta.Y);
double slopeWidth = dy / tangent;
if (dx > slopeWidth)
{
return delta.X > 0d ? new Point(target.X - slopeWidth, source.Y) : new Point(source.X - slopeWidth, target.Y);
}
double slopeHeight = dx * tangent;
if (dy > slopeHeight)
{
if (delta.Y > 0d)
{
// handle top left
return delta.X < 0d ? new Point(source.X, target.Y - slopeHeight) : new Point(target.X, source.Y + slopeHeight);
}
// handle bottom left
if (delta.X < 0d)
{
return new Point(source.X, target.Y + slopeHeight);
}
}
return new Point(target.X, source.Y - slopeHeight);
}
}
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Nodify
{
/// <summary>
/// Represents a cubic bezier curve.
/// </summary>
public class Connection : BaseConnection
{
static Connection()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Connection), new FrameworkPropertyMetadata(typeof(Connection)));
NodifyEditor.CuttingConnectionTypes.Add(typeof(Connection));
}
private const double _baseOffset = 100d;
private const double _offsetGrowthRate = 25d;
protected override ((Point ArrowStartSource, Point ArrowStartTarget), (Point ArrowEndSource, Point ArrowEndTarget)) DrawLineGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1, p2, p3) = GetBezierControlPoints(source, target);
context.BeginFigure(source, false, false);
context.LineTo(p0, true, true);
context.BezierTo(p1, p2, p3, true, true);
context.LineTo(target, true, true);
return ((target, source), (source, target));
}
protected override void DrawDirectionalArrowsGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1, p2, p3) = GetBezierControlPoints(source, target);
double spacing = 1d / (DirectionalArrowsCount + 1);
for (int i = 1; i <= DirectionalArrowsCount; i++)
{
double t = (spacing * i + DirectionalArrowsOffset).WrapToRange(0d, 1d);
var to = InterpolateCubicBezier(p0, p1, p2, p3, t);
var direction = GetBezierTangent(p0, p1, p2, p3, t);
DrawDirectionalArrowheadGeometry(context, direction, to);
}
}
protected override Point GetTextPosition(FormattedText text, Point source, Point target)
{
var (p0, p1, p2, p3) = GetBezierControlPoints(source, target);
var textCenter = new Vector(text.Width / 2, text.Height / 2);
return InterpolateCubicBezier(p0, p1, p2, p3, 0.5) - textCenter;
}
private (Point P0, Point P1, Point P2, Point P3) GetBezierControlPoints(Point source, Point target)
{
double direction = Direction == ConnectionDirection.Forward ? 1d : -1d;
var spacing = new Vector(Spacing * direction, 0d);
var spacingVertical = new Vector(spacing.Y, spacing.X);
Point startPoint = source + (SourceOrientation == Orientation.Vertical ? spacingVertical : spacing);
Point endPoint = target - (TargetOrientation == Orientation.Vertical ? spacingVertical : spacing);
Vector delta = target - source;
double height = Math.Abs(delta.Y);
double width = Math.Abs(delta.X);
// Smooth curve when distance is lower than base offset
double smooth = Math.Min(_baseOffset, height);
// Calculate offset based on distance
double offset = Math.Max(smooth, width / 2d);
// Grow slowly with distance
offset = Math.Min(_baseOffset + Math.Sqrt(width * _offsetGrowthRate), offset);
var controlPoint = new Vector(offset * direction, 0d);
var controlPointVertical = new Vector(controlPoint.Y, controlPoint.X);
// Avoid sharp bend if orientation different (when close to each other)
if (TargetOrientation != SourceOrientation)
{
controlPoint *= 0.5;
}
Point p0 = startPoint;
Point p1 = startPoint + (SourceOrientation == Orientation.Vertical ? controlPointVertical : controlPoint);
Point p2 = endPoint - (TargetOrientation == Orientation.Vertical ? controlPointVertical : controlPoint);
Point p3 = endPoint;
return (p0, p1, p2, p3);
}
private static Vector GetBezierTangent(Point P0, Point P1, Point P2, Point P3, double t)
{
// Calculate the derivatives of the Bezier curve equation and negate the result
return -(-3 * (1 - t) * (1 - t) * (Vector)P0 +
(3 * (1 - t) * (1 - t) * (Vector)P1 - 6 * t * (1 - t) * (Vector)P1) +
(6 * t * (1 - t) * (Vector)P2 - 3 * t * t * (Vector)P2) +
3 * t * t * (Vector)P3);
}
protected static Point InterpolateCubicBezier(Point P0, Point P1, Point P2, Point P3, double t)
{
// B = (1 t)^3 * P0 + 3 * t * (1 t)^2 * P1 + 3 * t^2 * (1 t) * P2 + t^3 * P3
return (Point)
((Vector)P0 * (1 - t) * (1 - t) * (1 - t)
+ (Vector)P1 * 3 * t * (1 - t) * (1 - t)
+ (Vector)P2 * 3 * t * t * (1 - t)
+ (Vector)P3 * t * t * t);
}
}
}

View File

@@ -0,0 +1,200 @@
using Nodify.Interactivity;
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace Nodify
{
public class ConnectionContainer : ContentPresenter, IKeyboardFocusTarget<ConnectionContainer>
{
#region Dependency properties
public static readonly DependencyProperty IsSelectableProperty = DependencyProperty.Register(nameof(IsSelectable), typeof(bool), typeof(ConnectionContainer), new FrameworkPropertyMetadata(BoxValue.False));
public static readonly DependencyProperty IsSelectedProperty = System.Windows.Controls.Primitives.Selector.IsSelectedProperty.AddOwner(typeof(ConnectionContainer), new FrameworkPropertyMetadata(BoxValue.False, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsSelectedChanged));
private static void OnIsSelectedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var elem = (ConnectionContainer)d;
bool result = elem.IsSelectable && (bool)e.NewValue;
elem.IsSelected = result;
elem.OnSelectedChanged(result);
}
/// <summary>
/// Gets or sets whether this <see cref="ConnectionContainer"/> can be selected.
/// </summary>
public bool IsSelectable
{
get => BaseConnection.GetIsSelectable(Connection ?? this);
set => BaseConnection.SetIsSelectable(Connection ?? this, value);
}
/// <summary>
/// Gets or sets a value that indicates whether this <see cref="ConnectionContainer"/> is selected.
/// Can only be set if <see cref="IsSelectable"/> is true.
/// </summary>
public bool IsSelected
{
get => (bool)GetValue(IsSelectedProperty);
set => SetValue(IsSelectedProperty, value);
}
#endregion
#region Routed events
public static readonly RoutedEvent SelectedEvent = System.Windows.Controls.Primitives.Selector.SelectedEvent.AddOwner(typeof(ConnectionContainer));
public static readonly RoutedEvent UnselectedEvent = System.Windows.Controls.Primitives.Selector.UnselectedEvent.AddOwner(typeof(ConnectionContainer));
/// <summary>
/// Occurs when this <see cref="ConnectionContainer"/> is selected.
/// </summary>
public event RoutedEventHandler Selected
{
add => AddHandler(SelectedEvent, value);
remove => RemoveHandler(SelectedEvent, value);
}
/// <summary>
/// Occurs when this <see cref="ConnectionContainer"/> is unselected.
/// </summary>
public event RoutedEventHandler Unselected
{
add => AddHandler(UnselectedEvent, value);
remove => RemoveHandler(UnselectedEvent, value);
}
#endregion
private FrameworkElement? _connection;
private SelectionType? _selectionType;
public Rect Bounds => ConnectionFocusTarget.Bounds;
ConnectionContainer IKeyboardFocusTarget<ConnectionContainer>.Element => this;
private IKeyboardFocusTarget<FrameworkElement> ConnectionFocusTarget => Connection as IKeyboardFocusTarget<FrameworkElement>
?? throw new NotSupportedException($"Custom connections must implement {nameof(IKeyboardFocusTarget<FrameworkElement>)} for keyboard navigation. Or disable keyboard navigation for the connections layer.");
public FrameworkElement? Connection => _connection ??= BaseConnection.PrioritizeBaseConnectionForSelection
? this.GetChildOfType<BaseConnection>() ?? this.GetChildOfType<FrameworkElement>()
: this.GetChildOfType<FrameworkElement>();
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));
}
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();
}
}
/// <summary>
/// Raises the <see cref="SelectedEvent"/> or <see cref="UnselectedEvent"/> based on <paramref name="newValue"/>.
/// Called when the <see cref="IsSelected"/> value is changed.
/// </summary>
/// <param name="newValue">True if selected, false otherwise.</param>
private void OnSelectedChanged(bool newValue)
{
BaseConnection.SetIsSelected(Connection, newValue);
RaiseEvent(new RoutedEventArgs(newValue ? SelectedEvent : UnselectedEvent, this));
}
protected override void OnMouseDown(MouseButtonEventArgs e)
{
EditorGestures.ConnectionGestures gestures = EditorGestures.Mappings.Connection;
if (IsSelectable && gestures.Selection.Select.Matches(e.Source, e))
{
_selectionType = gestures.Selection.GetSelectionType(e);
}
// Replaces the current selection when right-clicking on an element that has a context menu and is not selected.
// Applies only when the select gesture is not right click.
else if (e.ChangedButton == MouseButton.Right && Connection?.ContextMenu != null)
{
_selectionType = IsSelected ? SelectionType.Append : SelectionType.Replace;
}
if (_selectionType.HasValue)
{
Focus();
e.Handled = true;
}
}
protected override void OnMouseUp(MouseButtonEventArgs e)
{
if (_selectionType.HasValue)
{
// Determine whether the current selection should remain intact or be replaced by the clicked item.
// If the right mouse button is pressed on an already selected item, and the item either has an
// explicit context menu, the selection remains unchanged.
// This ensures that the context menu applies to the entire selection rather than only the clicked item.
bool allowContextMenu = e.ChangedButton == MouseButton.Right && IsSelected && Connection?.ContextMenu != null;
if (!allowContextMenu)
{
Select(_selectionType.Value);
}
_selectionType = null;
}
}
/// <summary>
/// Modifies the selection state of the current item based on the specified selection type.
/// </summary>
/// <param name="type">The type of selection to perform.</param>
public void Select(SelectionType type)
{
switch (type)
{
case SelectionType.Append:
IsSelected = true;
break;
case SelectionType.Remove:
IsSelected = false;
break;
case SelectionType.Invert:
IsSelected = !IsSelected;
break;
case SelectionType.Replace:
Selector.Select(this);
break;
}
}
}
}

View File

@@ -0,0 +1,291 @@
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
{
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));
private static void OnCanSelectMultipleItemsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((ConnectionsMultiSelector)d).CanSelectMultipleItemsBase = (bool)e.NewValue;
private static object CoerceCanSelectMultipleItems(DependencyObject d, object baseValue)
=> ((ConnectionsMultiSelector)d).CanSelectMultipleItemsBase = (bool)baseValue;
private static void OnSelectedItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
=> ((ConnectionsMultiSelector)d).OnSelectedItemsSourceChanged((IList)e.OldValue, (IList)e.NewValue);
/// <summary>
/// Gets or sets the selected connections in the <see cref="NodifyEditor"/>.
/// </summary>
public new IList? SelectedItems
{
get => (IList?)GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value);
}
/// <summary>
/// Gets or sets whether multiple connections can be selected.
/// </summary>
public new bool CanSelectMultipleItems
{
get => (bool)GetValue(CanSelectMultipleItemsProperty);
set => SetValue(CanSelectMultipleItemsProperty, value);
}
private bool CanSelectMultipleItemsBase
{
get => base.CanSelectMultipleItems;
set => base.CanSelectMultipleItems = value;
}
#endregion
/// <summary>
/// Gets the <see cref="NodifyEditor"/> that owns this <see cref="ConnectionsMultiSelector"/>.
/// </summary>
public NodifyEditor? Editor { get; private set; }
/// <summary>
/// Gets a list of all <see cref="ConnectionContainer"/>s.
/// </summary>
/// <remarks>Cache the result before using it to avoid extra allocations.</remarks>
protected internal IReadOnlyCollection<ConnectionContainer> ConnectionContainers
{
get
{
ItemCollection items = Items;
var containers = new List<ConnectionContainer>(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<ConnectionContainer>(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<NodifyEditor>();
if (NodifyEditor.AutoRegisterConnectionsLayer)
{
Editor?.RegisterNavigationLayer(this);
}
}
#region Keyboard Navigation
public KeyboardNavigationLayerId Id { get; } = KeyboardNavigationLayerId.Connections;
public IKeyboardFocusTarget<UIElement>? LastFocusedElement => _focusNavigator.LastFocusedElement;
private readonly StatefulFocusNavigator<ConnectionContainer> _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<ConnectionContainer>() 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<ConnectionContainer>)container).Bounds))
?? containers.First();
}
return containerToFocus != null;
}
protected virtual ConnectionContainer? FindNextFocusTarget(ConnectionContainer currentContainer, TraversalRequest request)
{
var focusNavigator = new DirectionalFocusNavigator<ConnectionContainer>(ConnectionContainers);
var result = focusNavigator.FindNextFocusTarget(currentContainer, request);
return result?.Element;
}
protected virtual void OnElementFocused(IKeyboardFocusTarget<ConnectionContainer> target)
{
if (NodifyEditor.AutoPanOnNodeFocus)
{
Editor?.BringIntoView(target.Bounds, NodifyEditor.BringIntoViewEdgeOffset);
}
}
void IKeyboardNavigationLayer.OnActivated()
{
TryRestoreFocus();
}
void IKeyboardNavigationLayer.OnDeactivated()
{
}
#endregion
public void Select(ConnectionContainer container)
{
BeginUpdateSelectedItems();
var selected = base.SelectedItems;
selected.Clear();
selected.Add(container.DataContext);
#if NETCOREAPP3_0_OR_GREATER
// For some reason the ConnectionContainer.IsSelected property change is not triggered, which prevents the visual update of the child connection.
// To address this, we manually set the IsSelected property before it is automatically set to true by EndUpdateSelectedItems.
// Note: This approach will cause bindings to update out of order.
// It is recommended to handle undo/redo operations using the SelectionChanged event in this case.
container.IsSelected = true;
#endif
EndUpdateSelectedItems();
Editor?.UnselectAll();
}
#region Selection Handlers
private void OnSelectedItemsSourceChanged(IList oldValue, IList newValue)
{
if (oldValue is INotifyCollectionChanged oc)
{
oc.CollectionChanged -= OnSelectedItemsChanged;
}
if (newValue is INotifyCollectionChanged nc)
{
nc.CollectionChanged += OnSelectedItemsChanged;
}
IList selectedItems = base.SelectedItems;
BeginUpdateSelectedItems();
selectedItems.Clear();
if (newValue != null)
{
for (var i = 0; i < newValue.Count; i++)
{
selectedItems.Add(newValue[i]);
}
}
EndUpdateSelectedItems();
}
private void OnSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (!CanSelectMultipleItems)
return;
switch (e.Action)
{
case NotifyCollectionChangedAction.Reset:
base.SelectedItems.Clear();
break;
case NotifyCollectionChangedAction.Add:
IList? newItems = e.NewItems;
if (newItems != null)
{
IList selectedItems = base.SelectedItems;
for (var i = 0; i < newItems.Count; i++)
{
selectedItems.Add(newItems[i]);
}
}
break;
case NotifyCollectionChangedAction.Remove:
IList? oldItems = e.OldItems;
if (oldItems != null)
{
IList selectedItems = base.SelectedItems;
for (var i = 0; i < oldItems.Count; i++)
{
selectedItems.Remove(oldItems[i]);
}
}
break;
}
}
protected override void OnSelectionChanged(SelectionChangedEventArgs e)
{
base.OnSelectionChanged(e);
IList? selected = SelectedItems;
if (selected != null)
{
IList added = e.AddedItems;
for (var i = 0; i < added.Count; i++)
{
// Ensure no duplicates are added
if (!selected.Contains(added[i]))
{
selected.Add(added[i]);
}
}
IList removed = e.RemovedItems;
for (var i = 0; i < removed.Count; i++)
{
selected.Remove(removed[i]);
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Windows;
namespace Nodify.Events
{
/// <summary>
/// Represents the method that will handle <see cref="BaseConnection"/> related routed events.
/// </summary>
/// <param name="sender">The object where the event handler is attached.</param>
/// <param name="e">The event data.</param>
public delegate void ConnectionEventHandler(object sender, ConnectionEventArgs e);
/// <summary>
/// Provides data for <see cref="BaseConnection"/> related routed events.
/// </summary>
public class ConnectionEventArgs : RoutedEventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionEventArgs"/> class using the specified <see cref="Connection"/>.
/// </summary>
/// <param name="connection">The <see cref="FrameworkElement.DataContext"/> of a related <see cref="BaseConnection"/>.</param>
public ConnectionEventArgs(object connection)
=> Connection = connection;
/// <summary>
/// Gets or sets the location where the connection should be split.
/// </summary>
public Point SplitLocation { get; set; }
/// <summary>
/// Gets the <see cref="FrameworkElement.DataContext"/> of the <see cref="BaseConnection"/> associated with this event.
/// </summary>
public object Connection { get; }
protected override void InvokeEventHandler(Delegate genericHandler, object genericTarget)
=> ((ConnectionEventHandler)genericHandler)(genericTarget, this);
}
}

View File

@@ -0,0 +1,173 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Nodify
{
/// <summary>
/// Represents a line that has an arrow indicating its <see cref="BaseConnection.Direction"/>.
/// </summary>
public class LineConnection : BaseConnection
{
public static readonly DependencyProperty CornerRadiusProperty = DependencyProperty.Register(nameof(CornerRadius), typeof(double), typeof(LineConnection), new FrameworkPropertyMetadata(BoxValue.Double5, FrameworkPropertyMetadataOptions.AffectsRender));
/// <summary>
/// The radius of the corners between the line segments.
/// </summary>
public double CornerRadius
{
get => (double)GetValue(CornerRadiusProperty);
set => SetValue(CornerRadiusProperty, value);
}
static LineConnection()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(LineConnection), new FrameworkPropertyMetadata(typeof(LineConnection)));
NodifyEditor.CuttingConnectionTypes.Add(typeof(LineConnection));
}
protected override ((Point ArrowStartSource, Point ArrowStartTarget), (Point ArrowEndSource, Point ArrowEndTarget)) DrawLineGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1) = GetLinePoints(source, target);
context.BeginFigure(source, false, false);
if (CornerRadius > 0 && Spacing > 0)
{
AddSmoothCorner(context, source, p0, p1, CornerRadius);
AddSmoothCorner(context, p0, p1, target, CornerRadius);
}
else
{
context.LineTo(p0, true, true);
context.LineTo(p1, true, true);
}
context.LineTo(target, true, true);
return ((target, source), (source, target));
}
protected override void DrawDefaultArrowhead(StreamGeometryContext context, Point source, Point target, ConnectionDirection arrowDirection = ConnectionDirection.Forward, Orientation orientation = Orientation.Horizontal)
{
if (Spacing < 1d)
{
Vector delta = source - target;
double headWidth = ArrowSize.Width;
double headHeight = ArrowSize.Height / 2;
double angle = Math.Atan2(delta.Y, delta.X);
double sinT = Math.Sin(angle);
double cosT = Math.Cos(angle);
var from = new Point(target.X + (headWidth * cosT - headHeight * sinT), target.Y + (headWidth * sinT + headHeight * cosT));
var to = new Point(target.X + (headWidth * cosT + headHeight * sinT), target.Y - (headHeight * cosT - headWidth * sinT));
context.BeginFigure(target, true, true);
context.LineTo(from, true, true);
context.LineTo(to, true, true);
}
else
{
base.DrawDefaultArrowhead(context, source, target, arrowDirection, orientation);
}
}
protected override void DrawDirectionalArrowsGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1) = GetLinePoints(source, target);
var direction = p0 - p1;
double spacing = 1d / (DirectionalArrowsCount + 1);
for (int i = 1; i <= DirectionalArrowsCount; i++)
{
double t = (spacing * i + DirectionalArrowsOffset).WrapToRange(0d, 1d);
var to = InterpolateLineSegment(p0, p1, t);
DrawDirectionalArrowheadGeometry(context, direction, to);
}
}
private (Point P0, Point P1) GetLinePoints(Point source, Point target)
{
double direction = Direction == ConnectionDirection.Forward ? 1d : -1d;
var spacing = new Vector(Spacing * direction, 0d);
var spacingVertical = new Vector(spacing.Y, spacing.X);
var p0 = source + (SourceOrientation == Orientation.Vertical ? spacingVertical : spacing);
var p1 = target - (TargetOrientation == Orientation.Vertical ? spacingVertical : spacing);
return (p0, p1);
}
protected static Point InterpolateLineSegment(Point p0, Point p1, double t)
{
return (Point)((1 - t) * (Vector)p0 + t * (Vector)p1);
}
protected static ((Point SegmentStart, Point SegmentEnd), Point InterpolatedPoint) InterpolateLine(Point p0, Point p1, Point p2, Point p3, double t)
{
double length1 = (p1 - p0).Length;
double length2 = (p2 - p1).Length;
double length3 = (p3 - p2).Length;
double totalLength = length1 + length2 + length3;
double ratio1 = length1 / totalLength;
double ratio2 = length2 / totalLength;
double ratio3 = length3 / totalLength;
// Interpolate within the appropriate segment based on t
if (t <= ratio1)
{
return ((p0, p1), InterpolateLineSegment(p0, p1, t / ratio1));
}
else if (t <= ratio1 + ratio2)
{
return ((p1, p2), InterpolateLineSegment(p1, p2, (t - ratio1) / ratio2));
}
return ((p2, p3), InterpolateLineSegment(p2, p3, (t - ratio1 - ratio2) / ratio3));
}
protected static ((Point SegmentStart, Point SegmentEnd), Point InterpolatedPoint) InterpolateLine(Point p0, Point p1, Point p2, double t)
{
double length1 = (p1 - p0).Length;
double length2 = (p2 - p1).Length;
double totalLength = length1 + length2;
double ratio1 = length1 / totalLength;
double ratio2 = length2 / totalLength;
// Interpolate within the appropriate segment based on t
if (t <= ratio1)
{
return ((p0, p1), InterpolateLineSegment(p0, p1, t / ratio1));
}
return ((p1, p2), InterpolateLineSegment(p1, p2, (t - ratio1) / ratio2));
}
protected static void AddSmoothCorner(StreamGeometryContext context, Point start, Point corner, Point end, double radius)
{
double distAB = (corner - start).LengthSquared;
double distBC = (end - corner).LengthSquared;
double bendSize = Math.Sqrt(Math.Min(distAB, distBC)) / 2;
radius = Math.Min(bendSize, radius);
Vector directionToCorner = corner - start;
Vector directionFromCorner = end - corner;
if (directionToCorner.LengthSquared != 0)
directionToCorner.Normalize();
if (directionFromCorner.LengthSquared != 0)
directionFromCorner.Normalize();
Point curveStart = corner - directionToCorner * radius;
Point curveEnd = corner + directionFromCorner * radius;
context.LineTo(curveStart, true, true);
context.QuadraticBezierTo(corner, curveEnd, true, true);
}
}
}

View File

@@ -0,0 +1,11 @@
namespace Nodify.Interactivity
{
public static partial class ConnectionState
{
internal static void RegisterDefaultHandlers()
{
InputProcessor.Shared<BaseConnection>.RegisterHandlerFactory(elem => new Disconnect(elem));
InputProcessor.Shared<BaseConnection>.RegisterHandlerFactory(elem => new Split(elem));
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
public static partial class ConnectionState
{
/// <summary>
/// Represents a state in which a connection can be disconnected from its connectors based on specific gestures.
/// </summary>
public class Disconnect : InputElementState<BaseConnection>
{
/// <summary>
/// Initializes a new instance of the <see cref="Disconnect"/> class.
/// </summary>
/// <param name="connection">The <see cref="BaseConnection"/> element associated with this state.</param>
public Disconnect(BaseConnection connection) : base(connection)
{
}
protected override void OnMouseDown(MouseButtonEventArgs e)
{
EditorGestures.ConnectionGestures gestures = EditorGestures.Mappings.Connection;
if (gestures.Disconnect.Matches(e.Source, e))
{
Element.Focus();
e.Handled = true; // prevent interacting with the editor
}
}
protected override void OnMouseUp(MouseButtonEventArgs e)
{
EditorGestures.ConnectionGestures gestures = EditorGestures.Mappings.Connection;
if (gestures.Disconnect.Matches(e.Source, e))
{
Element.Remove();
e.Handled = true; // prevent opening context menu
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Windows.Input;
namespace Nodify.Interactivity
{
public static partial class ConnectionState
{
/// <summary>
/// Represents a state in which a connection can be split.
/// </summary>
public class Split : InputElementState<BaseConnection>
{
/// <summary>
/// Initializes a new instance of the <see cref="Split"/> class.
/// </summary>
/// <param name="connection">The <see cref="BaseConnection"/> element associated with this state.</param>
public Split(BaseConnection connection) : base(connection)
{
}
protected override void OnMouseDown(MouseButtonEventArgs e)
{
EditorGestures.ConnectionGestures gestures = EditorGestures.Mappings.Connection;
if (gestures.Split.Matches(e.Source, e))
{
Element.Focus();
Element.SplitAtLocation(e.GetPosition(Element));
e.Handled = true; // prevent interacting with the editor
}
}
}
}
}

View File

@@ -0,0 +1,273 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Nodify
{
public enum ConnectorPosition
{
Top,
Left,
Bottom,
Right
}
public class StepConnection : LineConnection
{
public static readonly DependencyProperty SourcePositionProperty = DependencyProperty.Register(nameof(SourcePosition), typeof(ConnectorPosition), typeof(StepConnection), new FrameworkPropertyMetadata(ConnectorPosition.Right, FrameworkPropertyMetadataOptions.AffectsRender, OnConnectorPositionChanged));
public static readonly DependencyProperty TargetPositionProperty = DependencyProperty.Register(nameof(TargetPosition), typeof(ConnectorPosition), typeof(StepConnection), new FrameworkPropertyMetadata(ConnectorPosition.Left, FrameworkPropertyMetadataOptions.AffectsRender, OnConnectorPositionChanged));
private static void OnConnectorPositionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var connection = (StepConnection)d;
connection.CoerceValue(DirectionProperty);
connection.CoerceValue(SourceOrientationProperty);
connection.CoerceValue(TargetOrientationProperty);
}
static StepConnection()
{
SourceOrientationProperty.OverrideMetadata(typeof(StepConnection), new FrameworkPropertyMetadata(Orientation.Horizontal, null, CoerceSourceOrientation));
TargetOrientationProperty.OverrideMetadata(typeof(StepConnection), new FrameworkPropertyMetadata(Orientation.Horizontal, null, CoerceTargetOrientation));
DirectionProperty.OverrideMetadata(typeof(StepConnection), new FrameworkPropertyMetadata(ConnectionDirection.Forward, null, CoerceConnectionDirection));
NodifyEditor.CuttingConnectionTypes.Add(typeof(StepConnection));
}
private static object CoerceSourceOrientation(DependencyObject d, object baseValue)
{
var connection = (StepConnection)d;
return connection.SourcePosition == ConnectorPosition.Left || connection.SourcePosition == ConnectorPosition.Right
? Orientation.Horizontal
: Orientation.Vertical;
}
private static object CoerceTargetOrientation(DependencyObject d, object baseValue)
{
var connection = (StepConnection)d;
return connection.TargetPosition == ConnectorPosition.Left || connection.TargetPosition == ConnectorPosition.Right
? Orientation.Horizontal
: Orientation.Vertical;
}
private static object CoerceConnectionDirection(DependencyObject d, object baseValue)
{
var connection = (StepConnection)d;
return connection.TargetPosition == ConnectorPosition.Left || connection.TargetPosition == ConnectorPosition.Top
? ConnectionDirection.Forward
: ConnectionDirection.Backward;
}
/// <summary>
/// Gets or sets the position of the source connector.
/// </summary>
public ConnectorPosition SourcePosition
{
get => (ConnectorPosition)GetValue(SourcePositionProperty);
set => SetValue(SourcePositionProperty, value);
}
/// <summary>
/// Gets or sets the position of the target connector.
/// </summary>
public ConnectorPosition TargetPosition
{
get => (ConnectorPosition)GetValue(TargetPositionProperty);
set => SetValue(TargetPositionProperty, value);
}
protected override ((Point ArrowStartSource, Point ArrowStartTarget), (Point ArrowEndSource, Point ArrowEndTarget)) DrawLineGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1, p2, p3) = GetLinePoints(source, target);
if (CornerRadius > 0)
{
DrawSmoothLine(context);
}
else
{
DrawDefaultLine(context);
}
if (Spacing < 1d)
{
return ((p1, source), (p2, target));
}
return ((target, source), (source, target));
void DrawDefaultLine(StreamGeometryContext context)
{
context.BeginFigure(source, false, false);
context.LineTo(p0, true, true);
context.LineTo(p1, true, true);
context.LineTo(p2, true, true);
context.LineTo(p3, true, true);
context.LineTo(target, true, true);
}
void DrawSmoothLine(StreamGeometryContext context)
{
context.BeginFigure(source, false, false);
AddSmoothCorner(context, source, p0, p1, CornerRadius);
if (p1 == p2)
{
// skip p1 or p2 because they overlap
AddSmoothCorner(context, p0, p1, p3, CornerRadius);
}
else
{
AddSmoothCorner(context, p0, p1, p2, CornerRadius);
AddSmoothCorner(context, p1, p2, p3, CornerRadius);
}
AddSmoothCorner(context, p2, p3, target, CornerRadius);
context.LineTo(target, true, true);
}
}
protected override Point GetTextPosition(FormattedText text, Point source, Point target)
{
var (p0, p1, p2, p3) = GetLinePoints(source, target);
Vector delta1 = p1 - p0;
Vector delta2 = p2 - p1;
Vector delta3 = p3 - p2;
var max = GetMax(delta1, GetMax(delta2, delta3));
if (max == delta1)
{
return new Point((p0.X + p1.X - text.Width) / 2, (p0.Y + p1.Y - text.Height) / 2);
}
else if (max == delta2)
{
return new Point((p2.X + p1.X - text.Width) / 2, (p2.Y + p1.Y - text.Height) / 2);
}
return new Point((p3.X + p2.X - text.Width) / 2, (p3.Y + p2.Y - text.Height) / 2);
static Vector GetMax(in Vector a, in Vector b)
=> a.LengthSquared > b.LengthSquared ? a : b;
}
protected override void DrawDirectionalArrowsGeometry(StreamGeometryContext context, Point source, Point target)
{
var (p0, p1, p2, p3) = GetLinePoints(source, target);
double spacing = 1d / (DirectionalArrowsCount + 1);
for (int i = 1; i <= DirectionalArrowsCount; i++)
{
double t = (spacing * i + DirectionalArrowsOffset).WrapToRange(0d, 1d);
var (segment, to) = InterpolateLine(p0, p1, p2, p3, t);
var direction = segment.SegmentStart - segment.SegmentEnd;
DrawDirectionalArrowheadGeometry(context, direction, to);
}
}
private (Point P0, Point P1, Point P2, Point P3) GetLinePoints(Point source, Point target)
{
var sourceDir = GetConnectorDirection(SourcePosition);
var targetDir = GetConnectorDirection(TargetPosition);
Point startPoint = source + new Vector(Spacing * sourceDir.X, Spacing * sourceDir.Y);
Point endPoint = target + new Vector(Spacing * targetDir.X, Spacing * targetDir.Y);
var connectionDir = GetConnectionDirection(startPoint, SourcePosition, endPoint);
bool horizontalConnection = connectionDir.X != 0;
if (IsOppositePosition(SourcePosition, TargetPosition))
{
var (p1, p2) = GetOppositePositionPoints();
return (startPoint, p1, p2, endPoint);
}
// same: left to left / top to top etc
if (SourcePosition == TargetPosition)
{
var p = GetSamePositionPoint();
return (startPoint, p, p, endPoint);
}
// mixed: right to bottom / left to top etc
bool isSameDir = horizontalConnection ? sourceDir.X == targetDir.Y : sourceDir.Y == targetDir.X;
bool startGreaterThanEnd = horizontalConnection ? startPoint.Y > endPoint.Y : startPoint.X > endPoint.X;
bool positiveDir = horizontalConnection ? sourceDir.X == 1 : sourceDir.Y == 1;
bool shouldFlip = positiveDir
? isSameDir ? !startGreaterThanEnd : startGreaterThanEnd
: isSameDir ? startGreaterThanEnd : !startGreaterThanEnd;
if (shouldFlip)
{
var sourceTarget = new Point(startPoint.X, endPoint.Y);
var targetSource = new Point(endPoint.X, startPoint.Y);
var pf = horizontalConnection ? sourceTarget : targetSource;
return (startPoint, pf, pf, endPoint);
}
var pp = GetSamePositionPoint();
return (startPoint, pp, pp, endPoint);
(Point P1, Point P2) GetOppositePositionPoints()
{
var center = startPoint + (endPoint - startPoint) / 2;
(Point P1, Point P2) verticalSplit = (new Point(center.X, startPoint.Y), new Point(center.X, endPoint.Y));
(Point P1, Point P2) horizontalSplit = (new Point(startPoint.X, center.Y), new Point(endPoint.X, center.Y));
if (horizontalConnection)
{
// left to right / right to left
return sourceDir.X == connectionDir.X ? verticalSplit : horizontalSplit;
}
// top to bottom / bottom to top
return sourceDir.Y == connectionDir.Y ? horizontalSplit : verticalSplit;
}
Point GetSamePositionPoint()
{
var sourceTarget = new Point(startPoint.X, endPoint.Y);
var targetSource = new Point(endPoint.X, startPoint.Y);
if (horizontalConnection)
{
// left to left / right to right
return sourceDir.X == connectionDir.X ? targetSource : sourceTarget;
}
// top to top / bottom to bottom
return sourceDir.Y == connectionDir.Y ? sourceTarget : targetSource;
}
static Point GetConnectionDirection(in Point source, ConnectorPosition sourcePosition, in Point target)
{
return sourcePosition == ConnectorPosition.Left || sourcePosition == ConnectorPosition.Right
? new Point(Math.Sign(target.X - source.X), 0)
: new Point(0, Math.Sign(target.Y - source.Y));
}
static Point GetConnectorDirection(ConnectorPosition position)
=> position switch
{
ConnectorPosition.Top => new Point(0, -1),
ConnectorPosition.Left => new Point(-1, 0),
ConnectorPosition.Bottom => new Point(0, 1),
ConnectorPosition.Right => new Point(1, 0),
_ => default,
};
static bool IsOppositePosition(ConnectorPosition sourcePosition, ConnectorPosition targetPosition)
{
return sourcePosition == ConnectorPosition.Left && targetPosition == ConnectorPosition.Right
|| sourcePosition == ConnectorPosition.Right && targetPosition == ConnectorPosition.Left
|| sourcePosition == ConnectorPosition.Top && targetPosition == ConnectorPosition.Bottom
|| sourcePosition == ConnectorPosition.Bottom && targetPosition == ConnectorPosition.Top;
}
}
}
}