From 3dad36b935f0ffe92415201f15819ab62aa7ddd2 Mon Sep 17 00:00:00 2001 From: Emanuel Miroiu Date: Tue, 23 Jul 2024 20:28:54 +0300 Subject: [PATCH] Add a Minimap control to Nodify (#124) * Added a Minimap control to Nodify * Add MaxViewportOffset and Zoom event * Add ResizeToViewport * Add playground settings * Add IsReadOnly to minimap to allow disabling controls * Add documentation --- CHANGELOG.md | 1 + .../Editor/NodifyEditorView.xaml | 31 +++ .../Editor/NodifyEditorView.xaml.cs | 5 + Examples/Nodify.Playground/EditorSettings.cs | 7 +- .../Nodify.Playground/PlaygroundSettings.cs | 49 +++++ Examples/Nodify.Playground/PointEditor.cs | 7 +- .../Nodify.Playground/PointEditorView.xaml | 16 +- Examples/Nodify.Shapes/Canvas/CanvasView.xaml | 102 ++++++++++ .../Nodify.Shapes/Canvas/CanvasView.xaml.cs | 5 + Examples/Nodify.Shapes/MainWindow.xaml.cs | 2 + Nodify/Connections/BaseConnection.cs | 8 +- Nodify/Connections/Connector.cs | 6 +- Nodify/EditorGestures.cs | 29 +++ Nodify/Helpers/MathExtensions.cs | 14 ++ Nodify/Minimap/Minimap.cs | 192 ++++++++++++++++++ Nodify/Minimap/MinimapItem.cs | 19 ++ Nodify/Minimap/MinimapPanel.cs | 119 +++++++++++ Nodify/Minimap/SubtractConverter.cs | 20 ++ Nodify/Minimap/ZoomEventArgs.cs | 40 ++++ Nodify/NodifyEditor.cs | 7 +- Nodify/Themes/Brushes.xaml | 21 ++ Nodify/Themes/Controls.xaml | 32 +++ Nodify/Themes/Dark.xaml | 6 + Nodify/Themes/Light.xaml | 6 + Nodify/Themes/Nodify.xaml | 6 + Nodify/Themes/Styles/Controls.xaml | 3 + Nodify/Themes/Styles/DecoratorContainer.xaml | 1 - Nodify/Themes/Styles/Minimap.xaml | 100 +++++++++ docs/Connections-Overview.md | 20 +- docs/Minimap-Overview.md | 73 +++++++ docs/_Sidebar.md | 27 ++- 31 files changed, 943 insertions(+), 31 deletions(-) create mode 100644 Nodify/Minimap/Minimap.cs create mode 100644 Nodify/Minimap/MinimapItem.cs create mode 100644 Nodify/Minimap/MinimapPanel.cs create mode 100644 Nodify/Minimap/SubtractConverter.cs create mode 100644 Nodify/Minimap/ZoomEventArgs.cs create mode 100644 Nodify/Themes/Styles/Minimap.xaml create mode 100644 docs/Minimap-Overview.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f85f0d6e..1e2dbe02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ > - Breaking Changes: > - Features: +> - Added a Minimap control and EditorGestures.Minimap to Nodify > - Added ContentContainerStyle, HeaderContainerStyle and FooterContainerStyle dependency properties to Node > - Added BringIntoView that takes a Rect parameter to NodifyEditor > - Added the NodifyEditor's DataContext as the parameter of the ItemsSelectStartedCommand, ItemsSelectCompletedCommand, ItemsDragStartedCommand and ItemsDragCompletedCommand commands diff --git a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml index fb43c54f..5873f9f4 100644 --- a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml +++ b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml @@ -472,6 +472,37 @@ + + + + + + + + + + + diff --git a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs index aec6e0c4..8cab264e 100644 --- a/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs +++ b/Examples/Nodify.Playground/Editor/NodifyEditorView.xaml.cs @@ -10,5 +10,10 @@ public NodifyEditorView() { InitializeComponent(); } + + private void Minimap_Zoom(object sender, ZoomEventArgs e) + { + EditorInstance.ZoomAtPosition(e.Zoom, e.Location); + } } } diff --git a/Examples/Nodify.Playground/EditorSettings.cs b/Examples/Nodify.Playground/EditorSettings.cs index a2d6739f..f8fc2ba1 100644 --- a/Examples/Nodify.Playground/EditorSettings.cs +++ b/Examples/Nodify.Playground/EditorSettings.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Windows; namespace Nodify.Playground { @@ -398,14 +399,14 @@ public ArrowHeadShape ArrowHeadShape set => SetProperty(ref _arrowHeadShape, value); } - private PointEditor _connectionSourceOffset = new PointEditor { X = 14, Y = 0 }; + private PointEditor _connectionSourceOffset = new Size(14, 0); public PointEditor ConnectionSourceOffset { get => _connectionSourceOffset; set => SetProperty(ref _connectionSourceOffset, value); } - private PointEditor _connectionTargetOffset = new PointEditor { X = 14, Y = 0 }; + private PointEditor _connectionTargetOffset = new Size(14, 0); public PointEditor ConnectionTargetOffset { get => _connectionTargetOffset; @@ -426,7 +427,7 @@ public double DirectionalArrowsOffset set => SetProperty(ref _directionalArrowsOffset, value); } - private PointEditor _connectionArrowSize = new PointEditor { X = 8, Y = 8 }; + private PointEditor _connectionArrowSize = new Size(8, 8); public PointEditor ConnectionArrowSize { get => _connectionArrowSize; diff --git a/Examples/Nodify.Playground/PlaygroundSettings.cs b/Examples/Nodify.Playground/PlaygroundSettings.cs index 1ec98254..ce9517eb 100644 --- a/Examples/Nodify.Playground/PlaygroundSettings.cs +++ b/Examples/Nodify.Playground/PlaygroundSettings.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Windows; namespace Nodify.Playground { @@ -19,6 +20,26 @@ private PlaygroundSettings() () => Instance.EditorInputMode, val => Instance.EditorInputMode = val, "Editor input mode"), + new ProxySettingViewModel( + () => Instance.ShowMinimap, + val => Instance.ShowMinimap = val, + "Show minimap", + "Set Enable nodes dragging optimization to false for realtime updates"), + new ProxySettingViewModel( + () => Instance.DisableMinimapControls, + val => Instance.DisableMinimapControls = val, + "Disable minimap controls", + "Whether the minimap can move and zoom the viewport"), + new ProxySettingViewModel( + () => Instance.ResizeToViewport, + val => Instance.ResizeToViewport = val, + "Minimap resize to viewport", + "Whether the minimap should resized to also display the viewport"), + new ProxySettingViewModel( + () => Instance.MinimapMaxViewportOffset, + val => Instance.MinimapMaxViewportOffset = val, + "Minimap max viewport offset", + "The max position from the items extent that the viewport can move to"), new ProxySettingViewModel( () => Instance.ShowGridLines, val => Instance.ShowGridLines = val, @@ -76,6 +97,34 @@ public EditorInputMode EditorInputMode .Then(() => EditorGestures.Mappings.Apply(value)); } + private bool _showMinimap = true; + public bool ShowMinimap + { + get => _showMinimap; + set => SetProperty(ref _showMinimap, value); + } + + private bool _disableMinimapControls = false; + public bool DisableMinimapControls + { + get => _disableMinimapControls; + set => SetProperty(ref _disableMinimapControls, value); + } + + private bool _resizeToViewport = false; + public bool ResizeToViewport + { + get => _resizeToViewport; + set => SetProperty(ref _resizeToViewport, value); + } + + private PointEditor _minimapViewportOffset = new Size(2000, 2000); + public PointEditor MinimapMaxViewportOffset + { + get => _minimapViewportOffset; + set => SetProperty(ref _minimapViewportOffset, value); + } + private bool _shouldConnectNodes = true; public bool ShouldConnectNodes { diff --git a/Examples/Nodify.Playground/PointEditor.cs b/Examples/Nodify.Playground/PointEditor.cs index 7fc8e215..679a1e7c 100644 --- a/Examples/Nodify.Playground/PointEditor.cs +++ b/Examples/Nodify.Playground/PointEditor.cs @@ -54,6 +54,9 @@ public Size Size }); } + public string XLabel { get; set; } = "x"; + public string YLabel { get; set; } = "y"; + public static implicit operator PointEditor(Point point) { return new PointEditor @@ -68,7 +71,9 @@ public static implicit operator PointEditor(Size size) return new PointEditor { X = size.Width, - Y = size.Height + Y = size.Height, + XLabel = "w", + YLabel = "h" }; } } diff --git a/Examples/Nodify.Playground/PointEditorView.xaml b/Examples/Nodify.Playground/PointEditorView.xaml index eb6e758f..5063023d 100644 --- a/Examples/Nodify.Playground/PointEditorView.xaml +++ b/Examples/Nodify.Playground/PointEditorView.xaml @@ -20,17 +20,21 @@ - + + + + - + Margin="0 5 0 0"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - /// Splits the connection. Triggered by gesture. + /// Splits the connection. Triggered by gesture. /// Parameter is the location where the splitting ocurred. /// public ICommand? SplitCommand @@ -282,7 +282,7 @@ public ICommand? SplitCommand } /// - /// Removes this connection. Triggered by gesture. + /// Removes this connection. Triggered by gesture. /// Parameter is the location where the disconnect ocurred. /// public ICommand? DisconnectCommand @@ -352,14 +352,14 @@ public FontStretch FontStretch public static readonly RoutedEvent DisconnectEvent = EventManager.RegisterRoutedEvent(nameof(Disconnect), RoutingStrategy.Bubble, typeof(ConnectionEventHandler), typeof(BaseConnection)); public static readonly RoutedEvent SplitEvent = EventManager.RegisterRoutedEvent(nameof(Split), RoutingStrategy.Bubble, typeof(ConnectionEventHandler), typeof(BaseConnection)); - /// Triggered by the gesture. + /// Triggered by the gesture. public event ConnectionEventHandler Disconnect { add => AddHandler(DisconnectEvent, value); remove => RemoveHandler(DisconnectEvent, value); } - /// Triggered by the gesture. + /// Triggered by the gesture. public event ConnectionEventHandler Split { add => AddHandler(SplitEvent, value); diff --git a/Nodify/Connections/Connector.cs b/Nodify/Connections/Connector.cs index ea397bf8..6162b5d1 100644 --- a/Nodify/Connections/Connector.cs +++ b/Nodify/Connections/Connector.cs @@ -20,14 +20,14 @@ public class Connector : Control public static readonly RoutedEvent PendingConnectionDragEvent = EventManager.RegisterRoutedEvent(nameof(PendingConnectionDrag), RoutingStrategy.Bubble, typeof(PendingConnectionEventHandler), typeof(Connector)); public static readonly RoutedEvent DisconnectEvent = EventManager.RegisterRoutedEvent(nameof(Disconnect), RoutingStrategy.Bubble, typeof(ConnectorEventHandler), typeof(Connector)); - /// Triggered by the gesture. + /// Triggered by the gesture. public event PendingConnectionEventHandler PendingConnectionStarted { add => AddHandler(PendingConnectionStartedEvent, value); remove => RemoveHandler(PendingConnectionStartedEvent, value); } - /// Triggered by the gesture. + /// Triggered by the gesture. public event PendingConnectionEventHandler PendingConnectionCompleted { add => AddHandler(PendingConnectionCompletedEvent, value); @@ -43,7 +43,7 @@ public event PendingConnectionEventHandler PendingConnectionDrag remove => RemoveHandler(PendingConnectionDragEvent, value); } - /// Triggered by the gesture. + /// Triggered by the gesture. public event ConnectorEventHandler Disconnect { add => AddHandler(DisconnectEvent, value); diff --git a/Nodify/EditorGestures.cs b/Nodify/EditorGestures.cs index 97c69f81..e33fb2c0 100644 --- a/Nodify/EditorGestures.cs +++ b/Nodify/EditorGestures.cs @@ -234,6 +234,31 @@ public void Apply(GroupingNodeGestures gestures) } } + /// Gestures used by the control. + public class MinimapGestures + { + public MinimapGestures() + { + DragViewport = new MouseGesture(MouseAction.LeftClick); + ZoomModifierKey = ModifierKeys.None; + } + + /// Gesture to move the viewport inside the . + public InputGestureRef DragViewport { get; } + + /// The key modifier required to start zooming by mouse wheel. + /// Defaults to . + public ModifierKeys ZoomModifierKey { get; set; } + + /// Copies from the specified gestures. + /// The gestures to copy. + public void Apply(MinimapGestures gestures) + { + DragViewport.Value = gestures.DragViewport.Value; + ZoomModifierKey = gestures.ZoomModifierKey; + } + } + /// Gestures for the editor. public NodifyEditorGestures Editor { get; } = new NodifyEditorGestures(); @@ -249,6 +274,9 @@ public void Apply(GroupingNodeGestures gestures) /// Gestures for the grouping node. public GroupingNodeGestures GroupingNode { get; } = new GroupingNodeGestures(); + /// Gestures for the minimap. + public MinimapGestures Minimap { get; } = new MinimapGestures(); + /// Copies from the specified gestures. /// The gestures to copy. public void Apply(EditorGestures gestures) @@ -258,6 +286,7 @@ public void Apply(EditorGestures gestures) Connector.Apply(gestures.Connector); Connection.Apply(gestures.Connection); GroupingNode.Apply(gestures.GroupingNode); + Minimap.Apply(gestures.Minimap); } } } diff --git a/Nodify/Helpers/MathExtensions.cs b/Nodify/Helpers/MathExtensions.cs index 8c143b31..c840004a 100644 --- a/Nodify/Helpers/MathExtensions.cs +++ b/Nodify/Helpers/MathExtensions.cs @@ -10,5 +10,19 @@ public static double WrapToRange(this double value, double min, double max) return value < 0 ? value + range + min : value + min; } + + public static double Clamp(this double value, double min, double max) + { + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } } } diff --git a/Nodify/Minimap/Minimap.cs b/Nodify/Minimap/Minimap.cs new file mode 100644 index 00000000..8e9346b0 --- /dev/null +++ b/Nodify/Minimap/Minimap.cs @@ -0,0 +1,192 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Shapes; + +namespace Nodify +{ + /// + /// A minimap control that can position the viewport, and zoom in and out. + /// + [StyleTypedProperty(Property = nameof(ViewportStyle), StyleTargetType = typeof(Rectangle))] + [StyleTypedProperty(Property = nameof(ItemContainerStyle), StyleTargetType = typeof(MinimapItem))] + [TemplatePart(Name = ElementItemsHost, Type = typeof(Panel))] + public class Minimap : ItemsControl + { + protected const string ElementItemsHost = "PART_ItemsHost"; + + public static readonly DependencyProperty ViewportLocationProperty = NodifyEditor.ViewportLocationProperty.AddOwner(typeof(Minimap), new FrameworkPropertyMetadata(BoxValue.Point, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + public static readonly DependencyProperty ViewportSizeProperty = NodifyEditor.ViewportSizeProperty.AddOwner(typeof(Minimap)); + public static readonly DependencyProperty ViewportStyleProperty = DependencyProperty.Register(nameof(ViewportStyle), typeof(Style), typeof(Minimap)); + public static readonly DependencyProperty ExtentProperty = NodifyCanvas.ExtentProperty.AddOwner(typeof(Minimap)); + public static readonly DependencyProperty ItemsExtentProperty = DependencyProperty.Register(nameof(ItemsExtent), typeof(Rect), typeof(Minimap)); + public static readonly DependencyProperty MaxViewportOffsetProperty = DependencyProperty.Register(nameof(MaxViewportOffset), typeof(Size), typeof(Minimap), new FrameworkPropertyMetadata(new Size(2000, 2000))); + public static readonly DependencyProperty ResizeToViewportProperty = DependencyProperty.Register(nameof(ResizeToViewport), typeof(bool), typeof(Minimap)); + public static readonly DependencyProperty IsReadOnlyProperty = TextBoxBase.IsReadOnlyProperty.AddOwner(typeof(Minimap)); + + public static readonly RoutedEvent ZoomEvent = EventManager.RegisterRoutedEvent(nameof(Zoom), RoutingStrategy.Bubble, typeof(ZoomEventHandler), typeof(Minimap)); + + /// + public Point ViewportLocation + { + get => (Point)GetValue(ViewportLocationProperty); + set => SetValue(ViewportLocationProperty, value); + } + + /// + public Size ViewportSize + { + get => (Size)GetValue(ViewportSizeProperty); + set => SetValue(ViewportSizeProperty, value); + } + + /// + /// Gets or sets the style to use for the viewport rectangle. + /// + public Style ViewportStyle + { + get => (Style)GetValue(ViewportStyleProperty); + set => SetValue(ViewportStyleProperty, value); + } + + /// The area covered by the items and the viewport rectangle in graph space. + public Rect Extent + { + get => (Rect)GetValue(ExtentProperty); + set => SetValue(ExtentProperty, value); + } + + /// The area covered by the s in graph space. + public Rect ItemsExtent + { + get => (Rect)GetValue(ItemsExtentProperty); + set => SetValue(ItemsExtentProperty, value); + } + + /// The max position from the that the viewport can move to. + public Size MaxViewportOffset + { + get => (Size)GetValue(MaxViewportOffsetProperty); + set => SetValue(MaxViewportOffsetProperty, value); + } + + /// Whether the minimap should resize to also display the whole viewport. + public bool ResizeToViewport + { + get => (bool)GetValue(ResizeToViewportProperty); + set => SetValue(ResizeToViewportProperty, value); + } + + /// Whether the minimap can move and zoom the viewport. + public bool IsReadOnly + { + get => (bool)GetValue(IsReadOnlyProperty); + set => SetValue(IsReadOnlyProperty, value); + } + + /// Triggered when zooming in or out using the mouse wheel. + public event ZoomEventHandler Zoom + { + add => AddHandler(ZoomEvent, value); + remove => RemoveHandler(ZoomEvent, value); + } + + /// + /// Gets the panel that holds all the s. + /// + protected internal Panel ItemsHost { get; private set; } = default!; + + static Minimap() + { + DefaultStyleKeyProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(typeof(Minimap))); + ClipToBoundsProperty.OverrideMetadata(typeof(Minimap), new FrameworkPropertyMetadata(BoxValue.True)); + } + + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + ItemsHost = GetTemplateChild(ElementItemsHost) as Panel ?? throw new InvalidOperationException($"{ElementItemsHost} is missing or is not of type {nameof(Panel)}."); + } + + protected bool IsDragging { get; private set; } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + var gestures = EditorGestures.Mappings.Minimap; + if (!IsReadOnly && gestures.DragViewport.Matches(this, e)) + { + this.CaptureMouseSafe(); + IsDragging = true; + + SetViewportLocation(e.GetPosition(ItemsHost)); + + e.Handled = true; + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + if (IsDragging) + { + SetViewportLocation(e.GetPosition(ItemsHost)); + } + } + + private 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; + } + + protected override void OnMouseUp(MouseButtonEventArgs e) + { + var gestures = EditorGestures.Mappings.Minimap; + if (IsDragging && gestures.DragViewport.Matches(this, e)) + { + IsDragging = false; + } + + if (IsMouseCaptured && e.RightButton == MouseButtonState.Released && e.LeftButton == MouseButtonState.Released && e.MiddleButton == MouseButtonState.Released) + { + ReleaseMouseCapture(); + } + } + + protected override void OnMouseWheel(MouseWheelEventArgs e) + { + if (!IsReadOnly && !e.Handled && EditorGestures.Mappings.Minimap.ZoomModifierKey == Keyboard.Modifiers) + { + double zoom = Math.Pow(2.0, e.Delta / 3.0 / Mouse.MouseWheelDeltaForOneLine); + var location = ViewportLocation + (Vector)ViewportSize / 2; + + var args = new ZoomEventArgs(zoom, location) + { + RoutedEvent = ZoomEvent, + Source = this + }; + RaiseEvent(args); + + e.Handled = true; + } + } + + protected override DependencyObject GetContainerForItemOverride() + => new MinimapItem(); + + protected override bool IsItemItsOwnContainerOverride(object item) + => item is MinimapItem; + } +} diff --git a/Nodify/Minimap/MinimapItem.cs b/Nodify/Minimap/MinimapItem.cs new file mode 100644 index 00000000..d792a83b --- /dev/null +++ b/Nodify/Minimap/MinimapItem.cs @@ -0,0 +1,19 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Nodify +{ + public class MinimapItem : ContentControl + { + public static readonly DependencyProperty LocationProperty = ItemContainer.LocationProperty.AddOwner(typeof(MinimapItem), new FrameworkPropertyMetadata(BoxValue.Point, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault | FrameworkPropertyMetadataOptions.AffectsParentMeasure)); + + /// + /// Gets or sets the location of this inside the . + /// + public Point Location + { + get => (Point)GetValue(LocationProperty); + set => SetValue(LocationProperty, value); + } + } +} diff --git a/Nodify/Minimap/MinimapPanel.cs b/Nodify/Minimap/MinimapPanel.cs new file mode 100644 index 00000000..a8dd2d05 --- /dev/null +++ b/Nodify/Minimap/MinimapPanel.cs @@ -0,0 +1,119 @@ +using System; +using System.Windows; +using System.Windows.Controls; + +namespace Nodify +{ + internal class MinimapPanel : Panel + { + public static readonly DependencyProperty ViewportLocationProperty = NodifyEditor.ViewportLocationProperty.AddOwner(typeof(MinimapPanel), new FrameworkPropertyMetadata(BoxValue.Point, FrameworkPropertyMetadataOptions.AffectsMeasure)); + public static readonly DependencyProperty ViewportSizeProperty = NodifyEditor.ViewportSizeProperty.AddOwner(typeof(MinimapPanel), new FrameworkPropertyMetadata(BoxValue.Size, FrameworkPropertyMetadataOptions.AffectsMeasure)); + public static readonly DependencyProperty ExtentProperty = NodifyCanvas.ExtentProperty.AddOwner(typeof(MinimapPanel)); + public static readonly DependencyProperty ItemsExtentProperty = Minimap.ItemsExtentProperty.AddOwner(typeof(MinimapPanel)); + public static readonly DependencyProperty ResizeToViewportProperty = Minimap.ResizeToViewportProperty.AddOwner(typeof(MinimapPanel)); + + /// + public Point ViewportLocation + { + get => (Point)GetValue(ViewportLocationProperty); + set => SetValue(ViewportLocationProperty, value); + } + + /// + public Size ViewportSize + { + get => (Size)GetValue(ViewportSizeProperty); + set => SetValue(ViewportSizeProperty, value); + } + + /// + public Rect Extent + { + get => (Rect)GetValue(ExtentProperty); + set => SetValue(ExtentProperty, value); + } + + /// + public Rect ItemsExtent + { + get => (Rect)GetValue(ItemsExtentProperty); + set => SetValue(ItemsExtentProperty, value); + } + + /// + public bool ResizeToViewport + { + get => (bool)GetValue(ResizeToViewportProperty); + set => SetValue(ResizeToViewportProperty, value); + } + + protected override Size MeasureOverride(Size availableSize) + { + double minX = double.MaxValue; + double minY = double.MaxValue; + + double maxX = double.MinValue; + double maxY = double.MinValue; + + UIElementCollection children = InternalChildren; + for (int i = 0; i < children.Count; i++) + { + var item = (MinimapItem)children[i]; + item.Measure(availableSize); + + Size size = item.DesiredSize; + + if (item.Location.X < minX) + { + minX = item.Location.X; + } + + if (item.Location.Y < minY) + { + minY = item.Location.Y; + } + + double sizeX = item.Location.X + size.Width; + if (sizeX > maxX) + { + maxX = sizeX; + } + + double sizeY = item.Location.Y + size.Height; + if (sizeY > maxY) + { + maxY = sizeY; + } + } + + var itemsExtent = minX == double.MaxValue + ? new Rect(0, 0, 0, 0) + : new Rect(minX, minY, maxX - minX, maxY - minY); + + ItemsExtent = itemsExtent; + + if (ResizeToViewport) + { + itemsExtent.Union(new Rect(ViewportLocation, ViewportSize)); + } + + Extent = itemsExtent; + + double width = Math.Max(itemsExtent.Size.Width, ViewportSize.Width); + double height = Math.Max(itemsExtent.Height, ViewportSize.Height); + return new Size(width, height); + } + + protected override Size ArrangeOverride(Size finalSize) + { + UIElementCollection children = InternalChildren; + for (int i = 0; i < children.Count; i++) + { + var item = (MinimapItem)children[i]; + item.Arrange(new Rect(item.Location - (Vector)Extent.Location, item.DesiredSize)); + } + + return finalSize; + } + } +} diff --git a/Nodify/Minimap/SubtractConverter.cs b/Nodify/Minimap/SubtractConverter.cs new file mode 100644 index 00000000..67fbbe04 --- /dev/null +++ b/Nodify/Minimap/SubtractConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Nodify +{ + internal class SubtractConverter : IMultiValueConverter + { + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + double result = (double)values[0] - (double)values[1]; + return result; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/Nodify/Minimap/ZoomEventArgs.cs b/Nodify/Minimap/ZoomEventArgs.cs new file mode 100644 index 00000000..b706f28c --- /dev/null +++ b/Nodify/Minimap/ZoomEventArgs.cs @@ -0,0 +1,40 @@ +using System; +using System.Windows; + +namespace Nodify +{ + /// + /// Represents the method that will handle routed event. + /// + /// The object where the event handler is attached. + /// The event data. + public delegate void ZoomEventHandler(object sender, ZoomEventArgs e); + + /// + /// Provides data for routed event. + /// + public class ZoomEventArgs : RoutedEventArgs + { + /// + /// Initializes a new instance of the class using the specified and . + /// + public ZoomEventArgs(double zoom, Point location) + { + Zoom = zoom; + Location = location; + } + + /// + /// Gets the zoom amount. + /// + public double Zoom { get; } + + /// + /// Gets the location where the editor should zoom in. + /// + public Point Location { get; } + + protected override void InvokeEventHandler(Delegate genericHandler, object genericTarget) + => ((ZoomEventHandler)genericHandler)(genericTarget, this); + } +} diff --git a/Nodify/NodifyEditor.cs b/Nodify/NodifyEditor.cs index cfbfb237..de872a46 100644 --- a/Nodify/NodifyEditor.cs +++ b/Nodify/NodifyEditor.cs @@ -146,7 +146,7 @@ public event RoutedEventHandler ViewportUpdated public Transform ViewportTransform => (Transform)GetValue(ViewportTransformProperty); /// - /// Gets the size of the viewport. + /// Gets the size of the viewport in graph space (scaled by the ). /// public Size ViewportSize { @@ -163,7 +163,6 @@ public Point ViewportLocation set => SetValue(ViewportLocationProperty, value); } - /// /// Gets or sets the zoom factor of the viewport. /// @@ -758,12 +757,12 @@ protected override bool IsItemItsOwnContainerOverride(object item) /// /// Zoom in at the viewports center /// - public void ZoomIn() => ZoomAtPosition(Math.Pow(2.0, 120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine), (Point)((Vector)ViewportLocation + (Vector)ViewportSize / 2)); + public void ZoomIn() => ZoomAtPosition(Math.Pow(2.0, 120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine), ViewportLocation + (Vector)ViewportSize / 2); /// /// Zoom out at the viewports center /// - public void ZoomOut() => ZoomAtPosition(Math.Pow(2.0, -120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine), (Point)((Vector)ViewportLocation + (Vector)ViewportSize / 2)); + public void ZoomOut() => ZoomAtPosition(Math.Pow(2.0, -120.0 / 3.0 / Mouse.MouseWheelDeltaForOneLine), ViewportLocation + (Vector)ViewportSize / 2); /// /// Zoom at the specified location in graph space coordinates. diff --git a/Nodify/Themes/Brushes.xaml b/Nodify/Themes/Brushes.xaml index cd65ed0a..16347951 100644 --- a/Nodify/Themes/Brushes.xaml +++ b/Nodify/Themes/Brushes.xaml @@ -216,4 +216,25 @@ o:Freeze="True" Color="{DynamicResource PendingConnection.BackgroundColor}" /> + + + + + + + + + + \ No newline at end of file diff --git a/Nodify/Themes/Controls.xaml b/Nodify/Themes/Controls.xaml index 4bb46116..66619db2 100644 --- a/Nodify/Themes/Controls.xaml +++ b/Nodify/Themes/Controls.xaml @@ -200,4 +200,36 @@ Value="{StaticResource PendingConnection.BackgroundBrush}" /> + + + + + + + + \ No newline at end of file diff --git a/Nodify/Themes/Dark.xaml b/Nodify/Themes/Dark.xaml index da8509e9..4495e785 100644 --- a/Nodify/Themes/Dark.xaml +++ b/Nodify/Themes/Dark.xaml @@ -75,4 +75,10 @@ White #121212 + + #121212 + DodgerBlue + DodgerBlue + #2D2D30 + \ No newline at end of file diff --git a/Nodify/Themes/Light.xaml b/Nodify/Themes/Light.xaml index 5ea0c573..a9aa2ef2 100644 --- a/Nodify/Themes/Light.xaml +++ b/Nodify/Themes/Light.xaml @@ -75,4 +75,10 @@ White #5C6A98 + + #f2f3f5 + DodgerBlue + DodgerBlue + #5C6A98 + \ No newline at end of file diff --git a/Nodify/Themes/Nodify.xaml b/Nodify/Themes/Nodify.xaml index f5af2df9..36f25796 100644 --- a/Nodify/Themes/Nodify.xaml +++ b/Nodify/Themes/Nodify.xaml @@ -75,4 +75,10 @@ #E8E1F3 #2A1B47 + + #2A1B47 + #FD5618 + #FD5618 + #662D91 + \ No newline at end of file diff --git a/Nodify/Themes/Styles/Controls.xaml b/Nodify/Themes/Styles/Controls.xaml index 88206358..d41d2971 100644 --- a/Nodify/Themes/Styles/Controls.xaml +++ b/Nodify/Themes/Styles/Controls.xaml @@ -15,5 +15,8 @@ + + + \ No newline at end of file diff --git a/Nodify/Themes/Styles/DecoratorContainer.xaml b/Nodify/Themes/Styles/DecoratorContainer.xaml index 70817d18..6ca87de7 100644 --- a/Nodify/Themes/Styles/DecoratorContainer.xaml +++ b/Nodify/Themes/Styles/DecoratorContainer.xaml @@ -16,7 +16,6 @@ BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}" Padding="{TemplateBinding Padding}" - x:Name="Border" CornerRadius="3"> diff --git a/Nodify/Themes/Styles/Minimap.xaml b/Nodify/Themes/Styles/Minimap.xaml new file mode 100644 index 00000000..c6fd8658 --- /dev/null +++ b/Nodify/Themes/Styles/Minimap.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/Connections-Overview.md b/docs/Connections-Overview.md index 05cd0169..acfb56d3 100644 --- a/docs/Connections-Overview.md +++ b/docs/Connections-Overview.md @@ -1,20 +1,30 @@ Connections are created between two points. The `Source` and `Target` dependency properties are of type `Point` and are usually bound to a connector's `Anchor` point. +## Table of contents + +- [Base connection](#base-connection) +- [Line connection](#line-connection) +- [Circuit connection](#circuit-connection) +- [Bezier connection](#connection) +- [Step connection](#step-connection) +- [Pending connection](#pending-connection) + ## Base connection -The base class for all connections provided by the library is `BaseConnection` which derives from `Shape`. There's no restriction to derive from `BaseConnection` when you create a custom connection. +The base class for all connections provided by the library is `BaseConnection` which derives from `Shape`. There's no restriction to derive from `BaseConnection` when you create a custom connection. It exposes two commands with their corresponding events: - - `DisconnectCommand`, respectively `DisconnectEvent` - fired when the connection is clicked while holding `ALT` - - `SplitCommand`, respectively `SplitEvent` - fired when the connection is double-clicked + +- `DisconnectCommand`, respectively `DisconnectEvent` - fired when the connection is clicked while holding `ALT` +- `SplitCommand`, respectively `SplitEvent` - fired when the connection is double-clicked The `Direction` of a connection can have two values: - - `Forward` + +- `Forward` ![image](https://user-images.githubusercontent.com/12727904/192101918-af9b0da6-ecc8-48f7-bf4d-8f9fdd005153.png) ![image](https://user-images.githubusercontent.com/12727904/192101959-2cb9a837-1642-4e96-b2ef-eea5502a587f.png) - - `Backward` ![image](https://user-images.githubusercontent.com/12727904/192101941-a00e23db-07ae-49ac-a907-72e35ef67877.png) diff --git a/docs/Minimap-Overview.md b/docs/Minimap-Overview.md new file mode 100644 index 00000000..72d4b432 --- /dev/null +++ b/docs/Minimap-Overview.md @@ -0,0 +1,73 @@ +## Table of contents + +- [Moving the viewport](#panning) +- [Zooming](#zooming) +- [Customization](#customization) + +The `Minimap` control is a custom control designed to provide a synchronized and miniature view of items in a `NodifyEditor`. It inherits from `ItemsControl` and displays items through the `ItemsSource` property. Each item is wrapped in a `MinimapItem` container that requires the `Location`, `Width`, and `Height` properties to be set in the `ItemContainerStyle`. + +> [!TIP] +> For real-time movement of nodes inside the minimap, it's required to set `NodifyEditor.EnableDraggingContainersOptimizations` to `false`. + +The control also displays a viewport rectangle that represents the visible area in the editor and requires the `ViewportLocation` and `ViewportSize` properties to be set. + +```xml + + + + + +``` + +```csharp +private void OnMinimapZoom(object sender, ZoomEventArgs e) +{ + Editor.ZoomAtPosition(e.Zoom, e.Location); +} +``` + +> [!IMPORTANT] +> The `Width` and `Height` should be constrained by the parent container or set to constant values on the `Minimap` to prevent resizing to fit the content. + +## Panning + +Panning is done by holding click and dragging and can be disabled by setting the `IsReadOnly` property to `true`. The `ViewportLocation` is updated during panning, therefore it must be a two-way binding (binds two ways by default). + +The panning gesture can be configured by setting `EditorGestures.Mappings.Minimap.DragViewport` to the desired gesture. + +## Zooming + +Zooming is done by scrolling the mouse wheel and can be disabled by setting the `IsReadOnly` property to `true` or by not handling the `Zoom` event. + +The zooming modifier key can be configured by setting `EditorGestures.Mappings.Minimap.ZoomModifierKey` to the desired value. + +## Customization + +The `ViewportStyle` is used to customize the viewport rectangle. + +```xml + + + +``` + +The `MaxViewportOffset` property is used to restrict how far the viewport can be moved away from the items when [panning](#panning). + +The `ResizeToViewport` property changes the resizing behavior of the minimap. +If `true`, the minimap will resize to always display the viewport alongside the items. + +![scale-with-viewport](https://github.com/user-attachments/assets/7a8887bf-f3f4-44d7-8311-6d9ba7869d78) + +If `false`, the minimap will resize to only include the items, allowing the viewport to go out of bounds. + +![viewport-out-of-bounds](https://github.com/user-attachments/assets/5d3b388e-8e40-4bfe-af3b-4c12fb47548d) diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 97f40903..f170fcbc 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -1,9 +1,12 @@ -| [English](https://github.com/WYihei/nodify/wiki/Home) | [简体中文](https://github.com/WYihei/nodify/wiki/主页) -| --- | --- | -*** +| [English](https://github.com/WYihei/nodify/wiki/Home) | [简体中文](https://github.com/WYihei/nodify/wiki/主页) | +| ----------------------------------------------------- | ------------------------------------------------------ | + +--- + ## [Home](Home) [Getting Started](Getting-Started) + - [Hierarchy and terminology](Getting-Started#hierarchy-and-terminology) - [Content layers](Getting-Started#content-layers) - [Creating an editor](Getting-Started#creating-an-editor) @@ -12,6 +15,7 @@ - [Drawing a grid](Getting-Started#drawing-a-grid) [Editor overview](Editor-Overview) + - [Moving the viewport](Editor-Overview#panning) - [Zooming](Editor-Overview#zooming) - [Selecting items](Editor-Overview#selecting) @@ -19,21 +23,36 @@ - [Commands](Editor-Overview#commands) [ItemContainer overview](ItemContainer-Overview) + - [Selecting](ItemContainer-Overview#selecting) - [Moving](ItemContainer-Overview#moving-and-dragging) [Nodes overview](Nodes-Overview) + - [The node](Nodes-Overview#1-the-node-control) - [The grouping node](Nodes-Overview#2-the-groupingnode-control) - [The knot node](Nodes-Overview#3-the-knotnode-control) - [The state node](Nodes-Overview#4-the-statenode-control) [Connections overview](Connections-Overview) + - [Base connection](Connections-Overview#base-connection) +- [Bezier connection](Connections-Overview#connection) +- [Line connection](Connections-Overview#line-connection) +- [Circuit connection](Connections-Overview#circuit-connection) +- [Step connection](Connections-Overview#step-connection) - [Pending connection](Connections-Overview#pending-connection) [Connectors overview](Connectors-Overview) +- [NodeInput and NodeOutput](Connectors-Overview#nodeinput-and-nodeoutput) + +[Minimap overview](Minimap-Overview) + +- [Moving the viewport](Minimap-Overview#panning) +- [Zooming](Minimap-Overview#zooming) +- [Customization](Minimap-Overview#customization) + [API Reference](API-Reference) -[(FAQ) Frequently asked questions](FAQ) \ No newline at end of file +[(FAQ) Frequently asked questions](FAQ)