using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Data; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Navigation; using System.Windows.Shapes; using DynamicData; using NodeNetwork.Utilities; using NodeNetwork.ViewModels; using NodeNetwork.Views.Controls; using ReactiveUI; namespace NodeNetwork.Views { public partial class NetworkView : IViewFor { #region ViewModel public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register(nameof(ViewModel), typeof(NetworkViewModel), typeof(NetworkView), new PropertyMetadata(null)); public NetworkViewModel ViewModel { get => (NetworkViewModel)GetValue(ViewModelProperty); set => SetValue(ViewModelProperty, value); } object IViewFor.ViewModel { get => ViewModel; set => ViewModel = (NetworkViewModel)value; } #endregion #region NetworkViewportRegion /// /// The rectangle to use as a clipping mask for contentContainer /// public Rect NetworkViewportRegion { get { double left = Canvas.GetLeft(contentContainer); if (Double.IsNaN(left)) { left = 0; } double top = Canvas.GetTop(contentContainer); if (Double.IsNaN(top)) { top = 0; } if (contentContainer.RenderTransform is ScaleTransform) { GeneralTransform transform = this.TransformToDescendant(contentContainer); return transform.TransformBounds(new Rect(0, 0, this.ActualWidth, this.ActualHeight)); } return new Rect(-left, -top, this.ActualWidth, this.ActualHeight); } } private BindingExpressionBase _viewportBinding; #endregion #region Node move events public class NodeMovementEventArgs : EventArgs { public IEnumerable Nodes { get; } public NodeMovementEventArgs(IEnumerable nodes) => Nodes = nodes.ToList(); } //Start public class NodeMoveStartEventArgs : NodeMovementEventArgs { public DragStartedEventArgs DragEvent { get; } public NodeMoveStartEventArgs(IEnumerable nodes, DragStartedEventArgs dragEvent) : base(nodes) { DragEvent = dragEvent; } } public delegate void NodeMoveStartDelegate(object sender, NodeMoveStartEventArgs e); /// Occurs when a (set of) node(s) is selected and starts moving. public event NodeMoveStartDelegate NodeMoveStart; //Move public class NodeMoveEventArgs : NodeMovementEventArgs { public DragDeltaEventArgs DragEvent { get; } public NodeMoveEventArgs(IEnumerable nodes, DragDeltaEventArgs dragEvent) : base(nodes) { DragEvent = dragEvent; } } public delegate void NodeMoveDelegate(object sender, NodeMoveEventArgs e); /// Occurs one or more times as the mouse changes position when a (set of) node(s) is selected and has mouse capture. public event NodeMoveDelegate NodeMove; //End public class NodeMoveEndEventArgs : NodeMovementEventArgs { public DragCompletedEventArgs DragEvent { get; } public NodeMoveEndEventArgs(IEnumerable nodes, DragCompletedEventArgs dragEvent) : base(nodes) { DragEvent = dragEvent; } } public delegate void NodeMoveEndDelegate(object sender, NodeMoveEndEventArgs e); /// Occurs when a (set of) node(s) loses mouse capture. public event NodeMoveEndDelegate NodeMoveEnd; #endregion #region NetworkBackground public static readonly DependencyProperty NetworkBackgroundProperty = DependencyProperty.Register(nameof(NetworkBackground), typeof(Brush), typeof(NetworkView), new PropertyMetadata(null)); public Brush NetworkBackground { get => (Brush)GetValue(NetworkBackgroundProperty); set => SetValue(NetworkBackgroundProperty, value); } #endregion /// /// The element that is used as an origin for the position of the elements of the network. /// /// /// Can be used for calculating the mouse position relative to the network. /// /// Mouse.GetPosition(network.CanvasOriginElement) /// /// public IInputElement CanvasOriginElement => contentContainer; #region StartCutGesture public static readonly DependencyProperty StartCutGestureProperty = DependencyProperty.Register(nameof(StartCutGesture), typeof(MouseGesture), typeof(NetworkView), new PropertyMetadata(new MouseGesture(MouseAction.RightClick))); /// /// This mouse gesture starts a cut, making the cutline visible. Right click by default. /// public MouseGesture StartCutGesture { get => (MouseGesture)GetValue(StartCutGestureProperty); set => SetValue(StartCutGestureProperty, value); } #endregion #region StartSelectionRectangleGesture public static readonly DependencyProperty StartSelectionRectangleGestureProperty = DependencyProperty.Register(nameof(StartSelectionRectangleGesture), typeof(MouseGesture), typeof(NetworkView), new PropertyMetadata(new MouseGesture(MouseAction.LeftClick, ModifierKeys.Shift))); /// /// This mouse gesture starts a selection, making the selection rectangle visible. Left click + Shift by default. /// public MouseGesture StartSelectionRectangleGesture { get => (MouseGesture)GetValue(StartSelectionRectangleGestureProperty); set => SetValue(StartSelectionRectangleGestureProperty, value); } #endregion public NetworkView() { InitializeComponent(); if (DesignerProperties.GetIsInDesignMode(this)) { return; } SetupNodes(); SetupConnections(); SetupCutLine(); SetupViewportBinding(); SetupKeyboardShortcuts(); SetupErrorMessages(); SetupDragAndDrop(); SetupSelectionRectangle(); } #region Setup private void SetupNodes() { this.WhenActivated(d => d( this.BindList(ViewModel, vm => vm.Nodes, v => v.nodesControl.ItemsSource) )); } private void SetupConnections() { this.WhenActivated(d => { this.BindList(ViewModel, vm => vm.Connections, v => v.connectionsControl.ItemsSource).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.PendingConnection, v => v.pendingConnectionView.ViewModel).DisposeWith(d); this.Events().MouseMove .Select(e => e.GetPosition(contentContainer)) .BindTo(this, v => v.ViewModel.PendingConnection.LooseEndPoint) .DisposeWith(d); this.Events().MouseLeftButtonUp .Where(_ => ViewModel.PendingConnection != null) .Subscribe(_ => ViewModel.OnPendingConnectionDropped()) .DisposeWith(d); }); } private void SetupKeyboardShortcuts() { this.WhenActivated(d => { this.Events().MouseLeftButtonDown.Subscribe(_ => Focus()).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.DeleteSelectedNodes, v => v.deleteBinding.Command).DisposeWith(d); }); } private void SetupCutLine() { this.WhenActivated(d => { this.OneWayBind(ViewModel, vm => vm.CutLine.StartPoint.X, v => v.cutLine.X1).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.CutLine.StartPoint.Y, v => v.cutLine.Y1).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.CutLine.EndPoint.X, v => v.cutLine.X2).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.CutLine.EndPoint.Y, v => v.cutLine.Y2).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.CutLine.IsVisible, v => v.cutLine.Visibility, isVisible => isVisible ? Visibility.Visible : Visibility.Collapsed) .DisposeWith(d); bool cutGestured = false; dragCanvas.Events().MouseDown.Subscribe(e => { if (StartCutGesture.Matches(this, e)) { Point pos = e.GetPosition(contentContainer); ViewModel.CutLine.StartPoint = pos; ViewModel.CutLine.EndPoint = pos; cutGestured = true; e.Handled = true; } }).DisposeWith(d); dragCanvas.Events().MouseMove.Subscribe(e => { if (!ViewModel.CutLine.IsVisible && cutGestured) { ViewModel.StartCut(); } if (ViewModel.CutLine.IsVisible) { ViewModel.CutLine.EndPoint = e.GetPosition(contentContainer); ViewModel.CutLine.IntersectingConnections.Edit(l => { l.Clear(); l.AddRange(FindIntersectingConnections().Where(val => val.intersects).Select(val => val.con)); }); e.Handled = true; } }).DisposeWith(d); dragCanvas.Events().MouseUp.Subscribe(e => { cutGestured = false; if (ViewModel.CutLine.IsVisible) { //Do cuts ViewModel.FinishCut(); e.Handled = true; } }).DisposeWith(d); }); } private void SetupViewportBinding() { this.WhenActivated(d => { this.Bind(ViewModel, vm => vm.ZoomFactor, v => v.dragCanvas.ZoomFactor); this.Bind(ViewModel, vm => vm.MaxZoomLevel, v => v.dragCanvas.MaxZoomFactor); this.Bind(ViewModel, vm => vm.MinZoomLevel, v => v.dragCanvas.MinZoomFactor); this.Bind(ViewModel, vm => vm.DragOffset, v => v.dragCanvas.DragOffset); }); Binding binding = new Binding { Source = this, Path = new PropertyPath(nameof(NetworkViewportRegion)), Mode = BindingMode.OneWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }; _viewportBinding = BindingOperations.SetBinding(clippingGeometry, RectangleGeometry.RectProperty, binding); } private void SetupErrorMessages() { messageHostBorder.Visibility = Visibility.Collapsed; //Start collapsed messagePopup.VerticalOffset = -15; this.WhenActivated(d => { this.OneWayBind(ViewModel, vm => vm.LatestValidation.IsValid, v => v.messageHostBorder.Visibility, isValid => isValid ? Visibility.Collapsed : Visibility.Visible) .DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.LatestValidation.MessageViewModel, v => v.messageHost.ViewModel) .DisposeWith(d); this.WhenAnyValue(v => v.ViewModel.PendingConnection.Validation) .Select(_ => ViewModel.PendingConnection?.Validation?.MessageViewModel != null) .BindTo(this, v => v.messagePopup.IsOpen) .DisposeWith(d); this.WhenAnyValue(v => v.ViewModel.PendingConnection.Validation) .Select(_ => ViewModel.PendingConnection?.Validation?.MessageViewModel) .BindTo(this, v => v.messagePopupHost.ViewModel) .DisposeWith(d); this.WhenAnyValue(vm => vm.ViewModel.PendingConnection.BoundingBox) .Select(b => new Rect(contentContainer.TranslatePoint(b.TopLeft, this), contentContainer.TranslatePoint(b.BottomRight, this))) .BindTo(this, v => v.messagePopup.PlacementRectangle) .DisposeWith(d); this.WhenAnyValue(vm => vm.ViewModel.PendingConnection.BoundingBox) .Select(b => (b.Width / 2d) - (messagePopup.Child.RenderSize.Width / 2d)) .BindTo(this, v => v.messagePopup.HorizontalOffset) .DisposeWith(d); }); } private void SetupDragAndDrop() { this.WhenActivated(d => { this.OneWayBind(ViewModel, vm => vm.PendingNode, v => v.pendingNodeView.ViewModel).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.PendingNode, v => v.pendingNodeView.Visibility, node => node == null ? Visibility.Collapsed : Visibility.Visible) .DisposeWith(d); this.WhenAnyValue(v => v.ViewModel.PendingNode.Position).Subscribe(pos => { Canvas.SetLeft(pendingNodeView, pos.X); Canvas.SetTop(pendingNodeView, pos.Y); }).DisposeWith(d); this.Events().DragOver.Subscribe(e => { object data = e.Data.GetData("nodeVM"); NodeViewModel newNodeVm = data as NodeViewModel; ViewModel.PendingNode = newNodeVm; if (ViewModel.PendingNode != null) { ViewModel.PendingNode.Position = e.GetPosition(contentContainer); } e.Effects = newNodeVm != null ? DragDropEffects.Copy : DragDropEffects.None; }).DisposeWith(d); this.Events().Drop.Subscribe(e => { object data = e.Data.GetData("nodeVM"); NodeViewModel newNodeVm = data as NodeViewModel; if (newNodeVm != null) { this.ViewModel.PendingNode = new NodeViewModel(); //Fixes issue with newNodeVm sticking around in pendingNodeView, messing up position updates this.ViewModel.PendingNode = null; newNodeVm.Position = e.GetPosition(contentContainer); ViewModel.Nodes.Add(newNodeVm); } }).DisposeWith(d); this.Events().DragLeave.Subscribe(_ => ViewModel.PendingNode = null).DisposeWith(d); }); } private void SetupSelectionRectangle() { this.WhenActivated(d => { this.WhenAnyValue(vm => vm.ViewModel.SelectionRectangle.Rectangle.Left) .Subscribe(left => Canvas.SetLeft(selectionRectangle, left)) .DisposeWith(d); this.WhenAnyValue(vm => vm.ViewModel.SelectionRectangle.Rectangle.Top) .Subscribe(top => Canvas.SetTop(selectionRectangle, top)) .DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.SelectionRectangle.Rectangle.Width, v => v.selectionRectangle.Width).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.SelectionRectangle.Rectangle.Height, v => v.selectionRectangle.Height).DisposeWith(d); this.OneWayBind(ViewModel, vm => vm.SelectionRectangle.IsVisible, v => v.selectionRectangle.Visibility).DisposeWith(d); this.Events().PreviewMouseDown.Subscribe(e => { if (ViewModel != null && StartSelectionRectangleGesture.Matches(this, e)) { CaptureMouse(); dragCanvas.IsDraggingEnabled = false; ViewModel.StartRectangleSelection(); ViewModel.SelectionRectangle.StartPoint = e.GetPosition(contentContainer); ViewModel.SelectionRectangle.EndPoint = ViewModel.SelectionRectangle.StartPoint; } }).DisposeWith(d); this.Events().MouseMove.Subscribe(e => { if (ViewModel != null && ViewModel.SelectionRectangle.IsVisible) { ViewModel.SelectionRectangle.EndPoint = e.GetPosition(contentContainer); UpdateSelectionRectangleIntersections(); } }).DisposeWith(d); this.Events().MouseUp.Subscribe(e => { if (ViewModel != null && ViewModel.SelectionRectangle.IsVisible) { ViewModel.FinishRectangleSelection(); dragCanvas.IsDraggingEnabled = true; ReleaseMouseCapture(); } }).DisposeWith(d); }); } // Real, accurate but expensive hittesting /*private void UpdateSelectionRectangleIntersections() { RectangleGeometry geometry = new RectangleGeometry(ViewModel.SelectionRectangle.Rectangle); ViewModel.SelectionRectangle.IntersectingNodes.Clear(); VisualTreeHelper.HitTest(nodesControl, element => { if (element is NodeView) { //return HitTestFilterBehavior.ContinueSkipChildren; } return HitTestFilterBehavior.Continue; }, result => { if ((result.VisualHit as FrameworkElement)?.DataContext is NodeViewModel nodeVm && !ViewModel.SelectionRectangle.IntersectingNodes.Contains(nodeVm)) { Debug.WriteLine(result.VisualHit); ViewModel.SelectionRectangle.IntersectingNodes.Add(nodeVm); } return HitTestResultBehavior.Continue; }, new GeometryHitTestParameters(geometry)); }*/ // Approximate but cheap boundingbox-based hittesting private void UpdateSelectionRectangleIntersections() { var selectionRect = ViewModel.SelectionRectangle.Rectangle; var nodesHit = WPFUtils.FindDescendantsOfType(nodesControl, true) .Where(nodeView => { //return selectionRect.Contains(new Rect(nodeView.ViewModel.Position, nodeView.RenderSize)); var viewModel = (NodeViewModel)((dynamic)nodeView).ViewModel; return selectionRect.IntersectsWith(new Rect(viewModel.Position, nodeView.RenderSize)); }) .Select(view => (NodeViewModel)((dynamic)view).ViewModel); ViewModel.SelectionRectangle.IntersectingNodes.Clear(); ViewModel.SelectionRectangle.IntersectingNodes.AddRange(nodesHit); } #endregion #region Viewport bound updates private void DragCanvas_OnZoom(object source, ZoomEventArgs args) { _viewportBinding?.UpdateTarget(); } private void ContentContainer_OnLayoutUpdated(object sender, EventArgs e) { _viewportBinding?.UpdateTarget(); } #endregion #region Node move events private void OnNodeDragStart(object sender, DragStartedEventArgs e) { // Hacky fix for issue #78. A nested thumb being dragged would also drag the node around, which is incorrect. // For some reason, trying to stop the MouseMove event from bubbling up does not work, so instead we check // here what caused this drag event. Only the Thumb around the node may cause drag events. bool isCorrectSource = WPFUtils.GetVisualAncestorNLevelsUp((DependencyObject)e.OriginalSource, 6) == nodesControl; if (NodeMoveStart != null && isCorrectSource) { var args = new NodeMoveStartEventArgs(ViewModel.SelectedNodes.Items, e); NodeMoveStart(sender, args); } } private void OnNodeDrag(object sender, DragDeltaEventArgs e) { // See OnNodeDragStart bool isCorrectSource = WPFUtils.GetVisualAncestorNLevelsUp((DependencyObject)e.OriginalSource, 6) == nodesControl; if (isCorrectSource) { foreach (NodeViewModel node in ViewModel.SelectedNodes.Items) { node.Position = new Point(node.Position.X + e.HorizontalChange, node.Position.Y + e.VerticalChange); } if (NodeMove != null) { var args = new NodeMoveEventArgs(ViewModel.SelectedNodes.Items, e); NodeMove(sender, args); } } } private void OnNodeDragEnd(object sender, DragCompletedEventArgs e) { // See OnNodeDragStart bool isCorrectSource = WPFUtils.GetVisualAncestorNLevelsUp((DependencyObject)e.OriginalSource, 6) == nodesControl; if (NodeMoveEnd != null && isCorrectSource) { var args = new NodeMoveEndEventArgs(ViewModel.SelectedNodes.Items, e); NodeMoveEnd(sender, args); } } #endregion private void OnClickCanvas(object sender, MouseButtonEventArgs e) { ViewModel.ClearSelection(); } private IEnumerable<(ConnectionViewModel con, bool intersects)> FindIntersectingConnections() { foreach (ConnectionViewModel con in ViewModel.Connections.Items) { PathGeometry conGeom = ConnectionView.BuildSmoothBezier(con.Input.Port.CenterPoint, con.Input.PortPosition, con.Output.Port.CenterPoint, con.Output.PortPosition); LineGeometry cutLineGeom = new LineGeometry(ViewModel.CutLine.StartPoint, ViewModel.CutLine.EndPoint); bool hasIntersections = WPFUtils.GetIntersectionPoints(conGeom, cutLineGeom).Any(); yield return (con, hasIntersections); } } public void CenterAndZoomView() { if (ViewModel.Nodes.Count == 0) { return; } var bounding = ViewModel.Nodes.Items.Select(node => { var currentTopLeft = node.Position; var currentBottomRight = Point.Add(node.Position, new Vector(node.Size.Width, node.Size.Height)); var nodeBounding = new Rect(currentTopLeft, currentBottomRight); return nodeBounding; }).Aggregate((r1, r2) => { r1.Union(r2); return r1; }); this.dragCanvas?.SetViewport(bounding); } } }