From 35b593a31de54b5a8941ea7f1461473d78989ff3 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 4 Oct 2022 21:33:11 +0200 Subject: [PATCH] Node editor - Added copy/pasting --- .../Profile/Nodes/NodeConnectionEntity.cs | 16 +++++ .../Entities/Profile/Nodes/NodeEntity.cs | 17 +++++ .../Profile/Nodes/NodePinCollectionEntity.cs | 11 +++ .../Services/NodeEditor/Commands/AddNode.cs | 4 +- src/Artemis.UI/Models/NodesClipboardModel.cs | 72 +++++++++++++++++++ .../Screens/VisualScripting/CableView.axaml | 7 -- .../VisualScripting/CableView.axaml.cs | 18 +++-- .../Screens/VisualScripting/CableViewModel.cs | 12 +--- .../VisualScripting/NodeScriptView.axaml | 2 + .../VisualScripting/NodeScriptView.axaml.cs | 9 +++ .../VisualScripting/NodeScriptViewModel.cs | 49 +++++++++++-- 11 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 src/Artemis.UI/Models/NodesClipboardModel.cs diff --git a/src/Artemis.Storage/Entities/Profile/Nodes/NodeConnectionEntity.cs b/src/Artemis.Storage/Entities/Profile/Nodes/NodeConnectionEntity.cs index 028d9c638..b85678d53 100644 --- a/src/Artemis.Storage/Entities/Profile/Nodes/NodeConnectionEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/Nodes/NodeConnectionEntity.cs @@ -4,6 +4,22 @@ namespace Artemis.Storage.Entities.Profile.Nodes; public class NodeConnectionEntity { + public NodeConnectionEntity() + { + } + + public NodeConnectionEntity(NodeConnectionEntity nodeConnectionEntity) + { + SourceType = nodeConnectionEntity.SourceType; + SourceNode = nodeConnectionEntity.SourceNode; + TargetNode = nodeConnectionEntity.TargetNode; + SourcePinCollectionId = nodeConnectionEntity.SourcePinCollectionId; + SourcePinId = nodeConnectionEntity.SourcePinId; + TargetType = nodeConnectionEntity.TargetType; + TargetPinCollectionId = nodeConnectionEntity.TargetPinCollectionId; + TargetPinId = nodeConnectionEntity.TargetPinId; + } + public string SourceType { get; set; } public Guid SourceNode { get; set; } public Guid TargetNode { get; set; } diff --git a/src/Artemis.Storage/Entities/Profile/Nodes/NodeEntity.cs b/src/Artemis.Storage/Entities/Profile/Nodes/NodeEntity.cs index 85647e59c..5683e5c0f 100644 --- a/src/Artemis.Storage/Entities/Profile/Nodes/NodeEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/Nodes/NodeEntity.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace Artemis.Storage.Entities.Profile.Nodes; @@ -10,6 +11,22 @@ public class NodeEntity PinCollections = new List(); } + public NodeEntity(NodeEntity nodeEntity) + { + Id = nodeEntity.Id; + Type = nodeEntity.Type; + PluginId = nodeEntity.PluginId; + + Name = nodeEntity.Name; + Description = nodeEntity.Description; + IsExitNode = nodeEntity.IsExitNode; + X = nodeEntity.X; + Y = nodeEntity.Y; + Storage = nodeEntity.Storage; + + PinCollections = nodeEntity.PinCollections.Select(p => new NodePinCollectionEntity(p)).ToList(); + } + public Guid Id { get; set; } public string Type { get; set; } public Guid PluginId { get; set; } diff --git a/src/Artemis.Storage/Entities/Profile/Nodes/NodePinCollectionEntity.cs b/src/Artemis.Storage/Entities/Profile/Nodes/NodePinCollectionEntity.cs index 9076ead6e..e74a22780 100644 --- a/src/Artemis.Storage/Entities/Profile/Nodes/NodePinCollectionEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/Nodes/NodePinCollectionEntity.cs @@ -2,6 +2,17 @@ public class NodePinCollectionEntity { + public NodePinCollectionEntity() + { + } + + public NodePinCollectionEntity(NodePinCollectionEntity nodePinCollectionEntity) + { + Id = nodePinCollectionEntity.Id; + Direction = nodePinCollectionEntity.Direction; + Amount = nodePinCollectionEntity.Amount; + } + public int Id { get; set; } public int Direction { set; get; } public int Amount { get; set; } diff --git a/src/Artemis.UI.Shared/Services/NodeEditor/Commands/AddNode.cs b/src/Artemis.UI.Shared/Services/NodeEditor/Commands/AddNode.cs index c1f92dcc0..3d6033784 100644 --- a/src/Artemis.UI.Shared/Services/NodeEditor/Commands/AddNode.cs +++ b/src/Artemis.UI.Shared/Services/NodeEditor/Commands/AddNode.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Artemis.Core; namespace Artemis.UI.Shared.Services.NodeEditor.Commands; @@ -36,7 +37,8 @@ public class AddNode : INodeEditorCommand, IDisposable /// public void Execute() { - _nodeScript.AddNode(_node); + if (!_nodeScript.Nodes.Contains(_node)) + _nodeScript.AddNode(_node); _isRemoved = false; } diff --git a/src/Artemis.UI/Models/NodesClipboardModel.cs b/src/Artemis.UI/Models/NodesClipboardModel.cs new file mode 100644 index 000000000..842d5348d --- /dev/null +++ b/src/Artemis.UI/Models/NodesClipboardModel.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Artemis.Core; +using Artemis.Storage.Entities.Profile.Nodes; +using FluentAvalonia.Core; + +namespace Artemis.UI.Models; + +public class NodesClipboardModel +{ + public NodesClipboardModel(NodeScript nodeScript, List nodes) + { + nodeScript.Save(); + + // Grab all entities belonging to provided nodes + Nodes = nodeScript.Entity.Nodes.Where(e => nodes.Any(n => n.Id == e.Id)).ToList(); + // Grab all connections between provided nodes + Connections = nodeScript.Entity.Connections.Where(e => nodes.Any(n => n.Id == e.SourceNode) && nodes.Any(n => n.Id == e.TargetNode)).ToList(); + } + + public NodesClipboardModel() + { + Nodes = new List(); + Connections = new List(); + } + + public List Nodes { get; set; } + public List Connections { get; set; } + + public List Paste(NodeScript nodeScript, double x, double y) + { + if (!Nodes.Any()) + return new List(); + + nodeScript.Save(); + + // Copy the entities, not messing with the originals + List nodes = Nodes.Select(n => new NodeEntity(n)).ToList(); + List connections = Connections.Select(c => new NodeConnectionEntity(c)).ToList(); + + double xOffset = x - nodes.Min(n => n.X); + double yOffset = y - nodes.Min(n => n.Y); + + foreach (NodeEntity node in nodes) + { + // Give each node a new GUID, updating any connections to it + Guid newGuid = Guid.NewGuid(); + foreach (NodeConnectionEntity connection in connections) + { + if (connection.SourceNode == node.Id) + connection.SourceNode = newGuid; + else if (connection.TargetNode == node.Id) + connection.TargetNode = newGuid; + + // Only add the connection if this is the first time we hit it + if (!nodeScript.Entity.Connections.Contains(connection)) + nodeScript.Entity.Connections.Add(connection); + } + + node.Id = newGuid; + node.X += xOffset; + node.Y += yOffset; + nodeScript.Entity.Nodes.Add(node); + } + + nodeScript.Load(); + + // Return the newly created nodes + return nodeScript.Nodes.Where(n => nodes.Any(e => e.Id == n.Id)).ToList(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/VisualScripting/CableView.axaml b/src/Artemis.UI/Screens/VisualScripting/CableView.axaml index c1c7610d9..e18230f6f 100644 --- a/src/Artemis.UI/Screens/VisualScripting/CableView.axaml +++ b/src/Artemis.UI/Screens/VisualScripting/CableView.axaml @@ -24,11 +24,6 @@ Stroke="{CompiledBinding CableColor, Converter={StaticResource ColorToSolidColorBrushConverter}}" StrokeThickness="4" StrokeLineCap="Round"> - - - - - @@ -47,8 +42,6 @@ BorderThickness="2" CornerRadius="3" Padding="4" - Canvas.Left="{CompiledBinding ValuePoint.X}" - Canvas.Top="{CompiledBinding ValuePoint.Y}" IsVisible="{CompiledBinding DisplayValue}"> diff --git a/src/Artemis.UI/Screens/VisualScripting/CableView.axaml.cs b/src/Artemis.UI/Screens/VisualScripting/CableView.axaml.cs index c4d032f88..1cd822bf3 100644 --- a/src/Artemis.UI/Screens/VisualScripting/CableView.axaml.cs +++ b/src/Artemis.UI/Screens/VisualScripting/CableView.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Input; using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.ReactiveUI; +using Avalonia.Rendering; using ReactiveUI; namespace Artemis.UI.Screens.VisualScripting; @@ -29,9 +30,9 @@ public class CableView : ReactiveUserControl { _valueBorder.GetObservable(BoundsProperty).Subscribe(rect => _valueBorder.RenderTransform = new TranslateTransform(rect.Width / 2 * -1, rect.Height / 2 * -1)).DisposeWith(d); - ViewModel.WhenAnyValue(vm => vm.FromPoint).Subscribe(_ => Update()).DisposeWith(d); - ViewModel.WhenAnyValue(vm => vm.ToPoint).Subscribe(_ => Update()).DisposeWith(d); - Update(); + ViewModel.WhenAnyValue(vm => vm.FromPoint).Subscribe(_ => Update(true)).DisposeWith(d); + ViewModel.WhenAnyValue(vm => vm.ToPoint).Subscribe(_ => Update(false)).DisposeWith(d); + Update(true); }); } @@ -40,10 +41,12 @@ public class CableView : ReactiveUserControl AvaloniaXamlLoader.Load(this); } - private void Update() + private void Update(bool from) { // Workaround for https://github.com/AvaloniaUI/Avalonia/issues/4748 - _cablePath.Margin = _cablePath.Margin != new Thickness(0, 0, 0, 0) ? new Thickness(0, 0, 0, 0) : new Thickness(1, 1, 0, 0); + _cablePath.Margin = new Thickness(_cablePath.Margin.Left + 1, _cablePath.Margin.Top + 1, 0, 0); + if (_cablePath.Margin.Left > 2) + _cablePath.Margin = new Thickness(0, 0, 0, 0); PathFigure pathFigure = ((PathGeometry) _cablePath.Data).Figures.First(); BezierSegment segment = (BezierSegment) pathFigure.Segments!.First(); @@ -51,6 +54,11 @@ public class CableView : ReactiveUserControl segment.Point1 = new Point(ViewModel.FromPoint.X + CABLE_OFFSET, ViewModel.FromPoint.Y); segment.Point2 = new Point(ViewModel.ToPoint.X - CABLE_OFFSET, ViewModel.ToPoint.Y); segment.Point3 = new Point(ViewModel.ToPoint.X, ViewModel.ToPoint.Y); + + Canvas.SetLeft(_valueBorder, ViewModel.FromPoint.X + (ViewModel.ToPoint.X - ViewModel.FromPoint.X) / 2); + Canvas.SetTop(_valueBorder, ViewModel.FromPoint.Y + (ViewModel.ToPoint.Y - ViewModel.FromPoint.Y) / 2); + + _cablePath.InvalidateVisual(); } private void OnPointerEnter(object? sender, PointerEventArgs e) diff --git a/src/Artemis.UI/Screens/VisualScripting/CableViewModel.cs b/src/Artemis.UI/Screens/VisualScripting/CableViewModel.cs index 6d5db773c..1d44adecc 100644 --- a/src/Artemis.UI/Screens/VisualScripting/CableViewModel.cs +++ b/src/Artemis.UI/Screens/VisualScripting/CableViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; using Artemis.Core; @@ -27,7 +28,6 @@ public class CableViewModel : ActivatableViewModelBase private PinViewModel? _fromViewModel; private ObservableAsPropertyHelper? _toPoint; private PinViewModel? _toViewModel; - private ObservableAsPropertyHelper? _valuePoint; public CableViewModel(NodeScriptViewModel nodeScriptViewModel, IPin from, IPin to, ISettingsService settingsService) { @@ -63,12 +63,7 @@ public class CableViewModel : ActivatableViewModelBase .Switch() .ToProperty(this, vm => vm.ToPoint) .DisposeWith(d); - _valuePoint = this.WhenAnyValue(vm => vm.FromPoint, vm => vm.ToPoint).Select(tuple => new Point( - tuple.Item1.X + (tuple.Item2.X - tuple.Item1.X) / 2, - tuple.Item1.Y + (tuple.Item2.Y - tuple.Item1.Y) / 2 - )).ToProperty(this, vm => vm.ValuePoint) - .DisposeWith(d); - + // Not a perfect solution but this makes sure the cable never renders at 0,0 (can happen when the cable spawns before the pin ever rendered) _connected = this.WhenAnyValue(vm => vm.FromPoint, vm => vm.ToPoint) .Select(tuple => tuple.Item1 != new Point(0, 0) && tuple.Item2 != new Point(0, 0)) @@ -104,11 +99,10 @@ public class CableViewModel : ActivatableViewModelBase } public bool Connected => _connected?.Value ?? false; - public bool IsFirst => _from.ConnectedTo[0] == _to; + public bool IsFirst => _from.ConnectedTo.FirstOrDefault() == _to; public Point FromPoint => _fromPoint?.Value ?? new Point(); public Point ToPoint => _toPoint?.Value ?? new Point(); - public Point ValuePoint => _valuePoint?.Value ?? new Point(); public Color CableColor => _cableColor?.Value ?? new Color(255, 255, 255, 255); public void UpdateDisplayValue(bool hoveringOver) diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml b/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml index b442e5e6f..3a8c99638 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml @@ -30,6 +30,8 @@ + + diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml.cs b/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml.cs index 682f61bc0..92728f4d3 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml.cs +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml.cs @@ -14,6 +14,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.ReactiveUI; using Avalonia.Threading; +using Avalonia.VisualTree; using DynamicData.Binding; using ReactiveUI; @@ -39,6 +40,8 @@ public class NodeScriptView : ReactiveUserControl _zoomBorder.AddHandler(PointerReleasedEvent, CanvasOnPointerReleased, RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true); _zoomBorder.AddHandler(PointerWheelChangedEvent, ZoomOnPointerWheelChanged, RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true); + _zoomBorder.AddHandler(PointerMovedEvent, ZoomOnPointerMoved, RoutingStrategies.Direct | RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true); + this.WhenActivated(d => { ViewModel!.AutoFitRequested += ViewModelOnAutoFitRequested; @@ -67,6 +70,12 @@ public class NodeScriptView : ReactiveUserControl e.Handled = true; } + private void ZoomOnPointerMoved(object? sender, PointerEventArgs e) + { + if (ViewModel != null) + ViewModel.PastePosition = e.GetPosition(_grid); + } + private void ShowPickerAt(Point point) { if (ViewModel == null) diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs b/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs index 8c0adc5b9..00ab99900 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs @@ -6,9 +6,12 @@ using System.Linq; using System.Reactive; using System.Reactive.Linq; using System.Reactive.Subjects; +using System.Text; +using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Events; using Artemis.Core.Services; +using Artemis.UI.Models; using Artemis.UI.Ninject.Factories; using Artemis.UI.Screens.VisualScripting.Pins; using Artemis.UI.Shared; @@ -16,6 +19,7 @@ using Artemis.UI.Shared.Services.NodeEditor; using Artemis.UI.Shared.Services.NodeEditor.Commands; using Avalonia; using Avalonia.Controls.Mixins; +using Avalonia.Input; using DynamicData; using DynamicData.Binding; using ReactiveUI; @@ -24,6 +28,8 @@ namespace Artemis.UI.Screens.VisualScripting; public class NodeScriptViewModel : ActivatableViewModelBase { + public const string CLIPBOARD_DATA_FORMAT = "Artemis.Nodes"; + private readonly INodeEditorService _nodeEditorService; private readonly INodeService _nodeService; private readonly SourceList _nodeViewModels; @@ -33,6 +39,7 @@ public class NodeScriptViewModel : ActivatableViewModelBase private DragCableViewModel? _dragViewModel; private List? _initialNodeSelection; private Matrix _panMatrix; + private Point _pastePosition; public NodeScriptViewModel(NodeScript nodeScript, bool isPreview, INodeVmFactory nodeVmFactory, INodeService nodeService, INodeEditorService nodeEditorService) { @@ -86,8 +93,8 @@ public class NodeScriptViewModel : ActivatableViewModelBase ClearSelection = ReactiveCommand.Create(ExecuteClearSelection); DeleteSelected = ReactiveCommand.Create(ExecuteDeleteSelected); DuplicateSelected = ReactiveCommand.Create(ExecuteDuplicateSelected); - CopySelected = ReactiveCommand.Create(ExecuteCopySelected); - PasteSelected = ReactiveCommand.Create(ExecutePasteSelected); + CopySelected = ReactiveCommand.CreateFromTask(ExecuteCopySelected); + PasteSelected = ReactiveCommand.CreateFromTask(ExecutePasteSelected); } public NodeScript NodeScript { get; } @@ -118,6 +125,12 @@ public class NodeScriptViewModel : ActivatableViewModelBase set => RaiseAndSetIfChanged(ref _panMatrix, value); } + public Point PastePosition + { + get => _pastePosition; + set => RaiseAndSetIfChanged(ref _pastePosition, value); + } + public void DeleteSelectedNodes() { List toRemove = NodeViewModels.Where(vm => vm.IsSelected && !vm.Node.IsDefaultNode && !vm.Node.IsExitNode).ToList(); @@ -279,11 +292,39 @@ public class NodeScriptViewModel : ActivatableViewModelBase } } - private void ExecuteCopySelected() + private async Task ExecuteCopySelected() { + if (Application.Current?.Clipboard == null) + return; + + List nodes = NodeViewModels.Where(vm => vm.IsSelected).Select(vm => vm.Node).Where(n => !n.IsDefaultNode && !n.IsExitNode).ToList(); + DataObject dataObject = new(); + string copy = CoreJson.SerializeObject(new NodesClipboardModel(NodeScript, nodes), true); + dataObject.Set(CLIPBOARD_DATA_FORMAT, copy); + await Application.Current.Clipboard.SetDataObjectAsync(dataObject); } - private void ExecutePasteSelected() + private async Task ExecutePasteSelected() { + if (Application.Current?.Clipboard == null) + return; + + byte[]? bytes = (byte[]?) await Application.Current.Clipboard.GetDataAsync(CLIPBOARD_DATA_FORMAT); + if (bytes == null!) + return; + + NodesClipboardModel? nodesClipboardModel = CoreJson.DeserializeObject(Encoding.Unicode.GetString(bytes), true); + if (nodesClipboardModel == null) + return; + + List nodes = nodesClipboardModel.Paste(NodeScript, PastePosition.X, PastePosition.Y); + + using NodeEditorCommandScope scope = _nodeEditorService.CreateCommandScope(NodeScript, "Paste nodes"); + foreach (INode node in nodes) + _nodeEditorService.ExecuteCommand(NodeScript, new AddNode(NodeScript, node)); + + // Select only the new nodes + foreach (NodeViewModel nodeViewModel in NodeViewModels) + nodeViewModel.IsSelected = nodes.Contains(nodeViewModel.Node); } } \ No newline at end of file