Browse Source

Merge pull request #716 from PixiEditor/improvements/06.01.2025

Improvements/06.01.2025
Krzysztof Krysiński 7 months ago
parent
commit
fbba44fefa
30 changed files with 566 additions and 202 deletions
  1. 4 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateFolder_ChangeInfo.cs
  2. 4 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateLayer_ChangeInfo.cs
  3. 2 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateStructureMember_ChangeInfo.cs
  4. 7 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs
  5. 14 11
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/OutputNode.cs
  6. 14 13
      src/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFillHelper.cs
  7. 52 0
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/DuplicateNode_Change.cs
  8. 1 1
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/NodeOperations.cs
  9. 2 1
      src/PixiEditor.ChangeableDocument/Changes/Structure/DuplicateLayer_Change.cs
  10. 2 0
      src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs
  11. 1 1
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml
  12. 2 1
      src/PixiEditor/Helpers/Constants/ClipboardDataFormats.cs
  13. 1 1
      src/PixiEditor/Models/Commands/Attributes/Commands/CommandAttribute.cs
  14. 1 1
      src/PixiEditor/Models/Commands/CommandController.cs
  15. 1 1
      src/PixiEditor/Models/Commands/Commands/Command.cs
  16. 68 15
      src/PixiEditor/Models/Controllers/ClipboardController.cs
  17. 1 1
      src/PixiEditor/Models/Controllers/ShortcutController.cs
  18. 17 0
      src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs
  19. 6 1
      src/PixiEditor/Models/DocumentModels/Public/DocumentStructureModule.cs
  20. 1 1
      src/PixiEditor/Models/Rendering/PreviewPainter.cs
  21. 136 128
      src/PixiEditor/Styles/Templates/NodeGraphView.axaml
  22. 1 1
      src/PixiEditor/Styles/Templates/NodeView.axaml
  23. 1 1
      src/PixiEditor/ViewModels/Document/DocumentManagerViewModel.cs
  24. 2 2
      src/PixiEditor/ViewModels/SubViewModels/AnimationsViewModel.cs
  25. 106 1
      src/PixiEditor/ViewModels/SubViewModels/ClipboardViewModel.cs
  26. 1 1
      src/PixiEditor/ViewModels/SubViewModels/LayersViewModel.cs
  27. 1 1
      src/PixiEditor/ViewModels/SubViewModels/NodeGraphManagerViewModel.cs
  28. 4 4
      src/PixiEditor/ViewModels/SubViewModels/SelectionViewModel.cs
  29. 112 10
      src/PixiEditor/Views/Nodes/NodeGraphView.cs
  30. 1 1
      src/PixiEditor/Views/Shortcuts/ShortcutBox.cs

+ 4 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateFolder_ChangeInfo.cs

@@ -1,4 +1,5 @@
 using System.Collections.Immutable;
 using System.Collections.Immutable;
+using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.Enums;
 using PixiEditor.ChangeableDocument.Enums;
@@ -19,9 +20,10 @@ public record class CreateFolder_ChangeInfo : CreateStructureMember_ChangeInfo
         bool maskIsVisible,
         bool maskIsVisible,
         ImmutableArray<NodePropertyInfo> Inputs,
         ImmutableArray<NodePropertyInfo> Inputs,
         ImmutableArray<NodePropertyInfo> Outputs,
         ImmutableArray<NodePropertyInfo> Outputs,
+        VecD position,
         NodeMetadata metadata
         NodeMetadata metadata
     ) : base(internalName, opacity, isVisible, clipToMemberBelow, name, blendMode, guidValue, hasMask,
     ) : base(internalName, opacity, isVisible, clipToMemberBelow, name, blendMode, guidValue, hasMask,
-        maskIsVisible, Inputs, Outputs, metadata)
+        maskIsVisible, Inputs, Outputs, position, metadata)
     {
     {
     }
     }
 
 
@@ -38,6 +40,7 @@ public record class CreateFolder_ChangeInfo : CreateStructureMember_ChangeInfo
             folder.EmbeddedMask is not null,
             folder.EmbeddedMask is not null,
             folder.MaskIsVisible.Value, CreatePropertyInfos(folder.InputProperties, true, folder.Id),
             folder.MaskIsVisible.Value, CreatePropertyInfos(folder.InputProperties, true, folder.Id),
             CreatePropertyInfos(folder.OutputProperties, false, folder.Id),
             CreatePropertyInfos(folder.OutputProperties, false, folder.Id),
+            folder.Position,
             new NodeMetadata(folder));
             new NodeMetadata(folder));
     }
     }
 }
 }

+ 4 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateLayer_ChangeInfo.cs

@@ -1,4 +1,5 @@
 using System.Collections.Immutable;
 using System.Collections.Immutable;
+using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
@@ -22,9 +23,10 @@ public record class CreateLayer_ChangeInfo : CreateStructureMember_ChangeInfo
         bool lockTransparency,
         bool lockTransparency,
         ImmutableArray<NodePropertyInfo> inputs,
         ImmutableArray<NodePropertyInfo> inputs,
         ImmutableArray<NodePropertyInfo> outputs,
         ImmutableArray<NodePropertyInfo> outputs,
+        VecD position,
         NodeMetadata metadata) :
         NodeMetadata metadata) :
         base(internalName, opacity, isVisible, clipToMemberBelow, name, blendMode, guidValue, hasMask,
         base(internalName, opacity, isVisible, clipToMemberBelow, name, blendMode, guidValue, hasMask,
-            maskIsVisible, inputs, outputs, metadata)
+            maskIsVisible, inputs, outputs, position, metadata)
     {
     {
         LockTransparency = lockTransparency;
         LockTransparency = lockTransparency;
     }
     }
@@ -46,6 +48,7 @@ public record class CreateLayer_ChangeInfo : CreateStructureMember_ChangeInfo
             layer is ITransparencyLockable { LockTransparency: true },
             layer is ITransparencyLockable { LockTransparency: true },
             CreatePropertyInfos(layer.InputProperties, true, layer.Id),
             CreatePropertyInfos(layer.InputProperties, true, layer.Id),
             CreatePropertyInfos(layer.OutputProperties, false, layer.Id),
             CreatePropertyInfos(layer.OutputProperties, false, layer.Id),
+            layer.Position,
             new NodeMetadata(layer.GetType())
             new NodeMetadata(layer.GetType())
         );
         );
     }
     }

+ 2 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateStructureMember_ChangeInfo.cs

@@ -18,8 +18,9 @@ public abstract record class CreateStructureMember_ChangeInfo(
     bool MaskIsVisible,
     bool MaskIsVisible,
     ImmutableArray<NodePropertyInfo> InputProperties,
     ImmutableArray<NodePropertyInfo> InputProperties,
     ImmutableArray<NodePropertyInfo> OutputProperties,
     ImmutableArray<NodePropertyInfo> OutputProperties,
+    VecD position,
     NodeMetadata Metadata
     NodeMetadata Metadata
-) : CreateNode_ChangeInfo(InternalName, Name, new VecD(0, 0), Id, InputProperties, OutputProperties, Metadata)
+) : CreateNode_ChangeInfo(InternalName, Name, position, Id, InputProperties, OutputProperties, Metadata)
 {
 {
     public ImmutableArray<NodePropertyInfo> InputProperties { get; init; } = InputProperties;
     public ImmutableArray<NodePropertyInfo> InputProperties { get; init; } = InputProperties;
     public ImmutableArray<NodePropertyInfo> OutputProperties { get; init; } = OutputProperties;
     public ImmutableArray<NodePropertyInfo> OutputProperties { get; init; } = OutputProperties;

+ 7 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs

@@ -66,6 +66,8 @@ public class CreateImageNode : Node, IPreviewRenderable
 
 
     private void OnPaint(RenderContext context, DrawingSurface surface)
     private void OnPaint(RenderContext context, DrawingSurface surface)
     {
     {
+        if(Output.Value == null || Output.Value.IsDisposed) return;
+        
         surface.Canvas.DrawSurface(Output.Value.DrawingSurface, 0, 0);
         surface.Canvas.DrawSurface(Output.Value.DrawingSurface, 0, 0);
     }
     }
 
 
@@ -101,6 +103,11 @@ public class CreateImageNode : Node, IPreviewRenderable
 
 
         var surface = Render(context);
         var surface = Render(context);
         
         
+        if (surface == null || surface.IsDisposed)
+        {
+            return false;
+        }
+        
         renderOn.Canvas.DrawSurface(surface.DrawingSurface, 0, 0);
         renderOn.Canvas.DrawSurface(surface.DrawingSurface, 0, 0);
         
         
         return true;
         return true;

+ 14 - 11
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/OutputNode.cs

@@ -10,11 +10,13 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 [NodeInfo("Output")]
 [NodeInfo("Output")]
 public class OutputNode : Node, IRenderInput, IPreviewRenderable
 public class OutputNode : Node, IRenderInput, IPreviewRenderable
 {
 {
+    public const string UniqueName = "PixiEditor.Output";
     public const string InputPropertyName = "Background";
     public const string InputPropertyName = "Background";
 
 
-    public RenderInputProperty Input { get; } 
-    
+    public RenderInputProperty Input { get; }
+
     private VecI? lastDocumentSize;
     private VecI? lastDocumentSize;
+
     public OutputNode()
     public OutputNode()
     {
     {
         Input = new RenderInputProperty(this, InputPropertyName, "BACKGROUND", null);
         Input = new RenderInputProperty(this, InputPropertyName, "BACKGROUND", null);
@@ -28,26 +30,27 @@ public class OutputNode : Node, IRenderInput, IPreviewRenderable
 
 
     protected override void OnExecute(RenderContext context)
     protected override void OnExecute(RenderContext context)
     {
     {
-        if(!string.IsNullOrEmpty(context.TargetOutput)) return;
-        
+        if (!string.IsNullOrEmpty(context.TargetOutput)) return;
+
         lastDocumentSize = context.DocumentSize;
         lastDocumentSize = context.DocumentSize;
-        
+
         int saved = context.RenderSurface.Canvas.Save();
         int saved = context.RenderSurface.Canvas.Save();
         context.RenderSurface.Canvas.ClipRect(new RectD(0, 0, context.DocumentSize.X, context.DocumentSize.Y));
         context.RenderSurface.Canvas.ClipRect(new RectD(0, 0, context.DocumentSize.X, context.DocumentSize.Y));
         Input.Value?.Paint(context, context.RenderSurface);
         Input.Value?.Paint(context, context.RenderSurface);
-        
+
         context.RenderSurface.Canvas.RestoreToCount(saved);
         context.RenderSurface.Canvas.RestoreToCount(saved);
     }
     }
 
 
     RenderInputProperty IRenderInput.Background => Input;
     RenderInputProperty IRenderInput.Background => Input;
+
     public RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
     public RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
     {
     {
         if (lastDocumentSize == null)
         if (lastDocumentSize == null)
         {
         {
             return null;
             return null;
         }
         }
-        
-        return new RectD(0, 0, lastDocumentSize.Value.X, lastDocumentSize.Value.Y); 
+
+        return new RectD(0, 0, lastDocumentSize.Value.X, lastDocumentSize.Value.Y);
     }
     }
 
 
     public bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
     public bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
@@ -56,12 +59,12 @@ public class OutputNode : Node, IRenderInput, IPreviewRenderable
         {
         {
             return false;
             return false;
         }
         }
-        
+
         int saved = renderOn.Canvas.Save();
         int saved = renderOn.Canvas.Save();
         Input.Value.Paint(context, renderOn);
         Input.Value.Paint(context, renderOn);
-        
+
         renderOn.Canvas.RestoreToCount(saved);
         renderOn.Canvas.RestoreToCount(saved);
-        
+
         return true;
         return true;
     }
     }
 }
 }

+ 14 - 13
src/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFillHelper.cs

@@ -63,7 +63,18 @@ public static class FloodFillHelper
             static (EmptyChunk _) => Colors.Transparent
             static (EmptyChunk _) => Colors.Transparent
         );
         );
 
 
-        if ((drawingColor.A == 0) || colorToReplace == drawingColor)
+        ulong uLongColor = drawingColor.ToULong();
+        Color colorSpaceCorrectedColor = drawingColor;
+        if (!document.ProcessingColorSpace.IsSrgb)
+        {
+            var srgbTransform = ColorSpace.CreateSrgb().GetTransformFunction();
+
+            var fixedColor = drawingColor.TransformColor(srgbTransform);
+            uLongColor = fixedColor.ToULong();
+            colorSpaceCorrectedColor = fixedColor;
+        }
+
+        if ((colorSpaceCorrectedColor.A == 0) || colorToReplace == colorSpaceCorrectedColor)
             return new();
             return new();
 
 
         RectI globalSelectionBounds = (RectI?)selection?.TightBounds ?? new RectI(VecI.Zero, document.Size);
         RectI globalSelectionBounds = (RectI?)selection?.TightBounds ?? new RectI(VecI.Zero, document.Size);
@@ -71,17 +82,6 @@ public static class FloodFillHelper
         // Pre-multiplies the color and convert it to floats. Since floats are imprecise, a range is used.
         // Pre-multiplies the color and convert it to floats. Since floats are imprecise, a range is used.
         // Used for faster pixel checking
         // Used for faster pixel checking
         ColorBounds colorRange = new(colorToReplace, tolerance);
         ColorBounds colorRange = new(colorToReplace, tolerance);
-        ulong uLongColor = drawingColor.ToULong();
-        if (chunkAtPos.IsT0 && !chunkAtPos.AsT0.Surface.ImageInfo.ColorSpace.IsSrgb)
-        {
-            if (chunkAtPos.AsT0.Surface?.ImageInfo.ColorSpace != null)
-            {
-                var srgbTransform = ColorSpace.CreateSrgb().GetTransformFunction();
-
-                var fixedColor = drawingColor.TransformColor(srgbTransform);
-                uLongColor = fixedColor.ToULong();
-            }
-        }
 
 
         Dictionary<VecI, Chunk> drawingChunks = new();
         Dictionary<VecI, Chunk> drawingChunks = new();
         HashSet<VecI> processedEmptyChunks = new();
         HashSet<VecI> processedEmptyChunks = new();
@@ -139,7 +139,7 @@ public static class FloodFillHelper
                 chunkPos,
                 chunkPos,
                 chunkSize,
                 chunkSize,
                 uLongColor,
                 uLongColor,
-                drawingColor,
+                colorSpaceCorrectedColor,
                 posOnChunk,
                 posOnChunk,
                 colorRange,
                 colorRange,
                 iter != 0);
                 iter != 0);
@@ -175,6 +175,7 @@ public static class FloodFillHelper
         ColorBounds bounds,
         ColorBounds bounds,
         bool checkFirstPixel)
         bool checkFirstPixel)
     {
     {
+        // color should be a fixed color
         if (referenceChunk.Surface.GetRawPixel(pos) == color || drawingChunk.Surface.GetRawPixel(pos) == color)
         if (referenceChunk.Surface.GetRawPixel(pos) == color || drawingChunk.Surface.GetRawPixel(pos) == color)
             return null;
             return null;
         if (checkFirstPixel && !bounds.IsWithinBounds(referenceChunk.Surface.GetRawPixel(pos)))
         if (checkFirstPixel && !bounds.IsWithinBounds(referenceChunk.Surface.GetRawPixel(pos)))

+ 52 - 0
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/DuplicateNode_Change.cs

@@ -0,0 +1,52 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
+
+namespace PixiEditor.ChangeableDocument.Changes.NodeGraph;
+
+internal class DuplicateNode_Change : Change
+{
+    private Guid nodeGuid;
+    
+    private Guid createdNodeGuid;
+
+    [GenerateMakeChangeAction]
+    public DuplicateNode_Change(Guid nodeGuid, Guid newGuid)
+    {
+        this.nodeGuid = nodeGuid;
+        createdNodeGuid = newGuid;
+    }
+
+    public override bool InitializeAndValidate(Document target)
+    {
+        return target.TryFindNode(nodeGuid, out Node node) && node.GetNodeTypeUniqueName() != OutputNode.UniqueName;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
+        out bool ignoreInUndo)
+    {
+        Node existingNode = target.FindNode(nodeGuid);
+        Node clone = existingNode.Clone();
+        clone.Id = createdNodeGuid;
+
+        target.NodeGraph.AddNode(clone);
+
+        ignoreInUndo = false;
+
+        return CreateNode_ChangeInfo.CreateFromNode(clone);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        var node = target.FindNode(createdNodeGuid);
+        target.NodeGraph.RemoveNode(node);
+        
+        node.Dispose();
+
+        return new DeleteNode_ChangeInfo(node.Id);
+    }
+
+    public override bool IsMergeableWith(Change other)
+    {
+        return other is DuplicateNode_Change;
+    }
+}

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/NodeOperations.cs

@@ -94,7 +94,7 @@ public static class NodeOperations
 
 
         changes.Add(new ConnectProperty_ChangeInfo(memberId, parentInput.Node.Id,
         changes.Add(new ConnectProperty_ChangeInfo(memberId, parentInput.Node.Id,
             toAddOutput.InternalPropertyName, parentInput.InternalPropertyName));
             toAddOutput.InternalPropertyName, parentInput.InternalPropertyName));
-
+        
         return changes;
         return changes;
     }
     }
 
 

+ 2 - 1
src/PixiEditor.ChangeableDocument/Changes/Structure/DuplicateLayer_Change.cs

@@ -1,5 +1,6 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
 using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
 using PixiEditor.ChangeableDocument.Changes.NodeGraph;
 using PixiEditor.ChangeableDocument.Changes.NodeGraph;
 
 
@@ -47,7 +48,7 @@ internal class DuplicateLayer_Change : Change
         target.NodeGraph.AddNode(clone);
         target.NodeGraph.AddNode(clone);
 
 
         operations.Add(CreateLayer_ChangeInfo.FromLayer(clone));
         operations.Add(CreateLayer_ChangeInfo.FromLayer(clone));
-
+        
         operations.AddRange(NodeOperations.AppendMember(targetInput, clone.Output, clone.Background, clone.Id));
         operations.AddRange(NodeOperations.AppendMember(targetInput, clone.Output, clone.Background, clone.Id));
 
 
         ignoreInUndo = false;
         ignoreInUndo = false;

+ 2 - 0
src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs

@@ -94,6 +94,8 @@ public class DocumentRenderer : IPreviewRenderable
         
         
         IsBusy = true;
         IsBusy = true;
         
         
+        if(previewRenderable is Node { IsDisposed: true }) return;
+        
         previewRenderable.RenderPreview(renderOn, context, elementToRenderName);
         previewRenderable.RenderPreview(renderOn, context, elementToRenderName);
         
         
         IsBusy = false;
         IsBusy = false;

+ 1 - 1
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml

@@ -120,7 +120,7 @@
             <system:String x:Key="icon-paste-as-new-layer">&#xe978;</system:String>
             <system:String x:Key="icon-paste-as-new-layer">&#xe978;</system:String>
             <system:String x:Key="icon-star">&#xe97c;</system:String>
             <system:String x:Key="icon-star">&#xe97c;</system:String>
             <system:String x:Key="icon-star-filled">&#xe979;</system:String>
             <system:String x:Key="icon-star-filled">&#xe979;</system:String>
-            <system:String x:Key="icon-reset">R</system:String>
+            <system:String x:Key="icon-reset">&#xE98A;</system:String>
             <system:String x:Key="icon-message">&#xE96F;</system:String>
             <system:String x:Key="icon-message">&#xE96F;</system:String>
             <system:String x:Key="icon-download">&#xE969;</system:String>
             <system:String x:Key="icon-download">&#xE969;</system:String>
             <system:String x:Key="icon-youtube">&#xE975;</system:String>
             <system:String x:Key="icon-youtube">&#xE975;</system:String>

+ 2 - 1
src/PixiEditor/Helpers/Constants/ClipboardDataFormats.cs

@@ -6,5 +6,6 @@ public static class ClipboardDataFormats
     public const string LayerIdList = "PixiEditor.LayerIdList";
     public const string LayerIdList = "PixiEditor.LayerIdList";
     public const string PositionFormat = "PixiEditor.Position";
     public const string PositionFormat = "PixiEditor.Position";
     public const string ImageSlashPng = "image/png";
     public const string ImageSlashPng = "image/png";
-    public const string DocumentFormat = "PixiEditor.Document"; 
+    public const string DocumentFormat = "PixiEditor.Document";
+    public const string NodeIdList = "PixiEditor.NodeIdList";
 }
 }

+ 1 - 1
src/PixiEditor/Models/Commands/Attributes/Commands/CommandAttribute.cs

@@ -17,7 +17,7 @@ internal partial class Command
 
 
         public string CanExecute { get; set; }
         public string CanExecute { get; set; }
         
         
-        public Type? ShortcutContext { get; set; }
+        public Type[]? ShortcutContexts { get; set; }
 
 
         /// <summary>
         /// <summary>
         /// Gets or sets the default shortcut key for this command
         /// Gets or sets the default shortcut key for this command

+ 1 - 1
src/PixiEditor/Models/Commands/CommandController.cs

@@ -260,7 +260,7 @@ internal class CommandController
                                 Parameter = basic.Parameter,
                                 Parameter = basic.Parameter,
                                 MenuItemPath = basic.MenuItemPath,
                                 MenuItemPath = basic.MenuItemPath,
                                 MenuItemOrder = basic.MenuItemOrder,
                                 MenuItemOrder = basic.MenuItemOrder,
-                                ShortcutContext = basic.ShortcutContext
+                                ShortcutContexts = basic.ShortcutContexts
                             });
                             });
                     }
                     }
                     else if (attribute is Attributes.Commands.Command.FilterAttribute menu)
                     else if (attribute is Attributes.Commands.Command.FilterAttribute menu)

+ 1 - 1
src/PixiEditor/Models/Commands/Commands/Command.cs

@@ -42,7 +42,7 @@ internal abstract partial class Command : PixiObservableObject
         }
         }
     }
     }
     
     
-    public Type? ShortcutContext { get; init; }
+    public Type[]? ShortcutContexts { get; init; }
 
 
     public string? MenuItemPath { get; init; }
     public string? MenuItemPath { get; init; }
 
 

+ 68 - 15
src/PixiEditor/Models/Controllers/ClipboardController.cs

@@ -27,6 +27,7 @@ using PixiEditor.Models.Commands.Attributes.Evaluators;
 using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.IO;
 using PixiEditor.Models.IO;
 using Drawie.Numerics;
 using Drawie.Numerics;
+using PixiEditor.Models.Handlers;
 using PixiEditor.Parser;
 using PixiEditor.Parser;
 using PixiEditor.ViewModels.Document;
 using PixiEditor.ViewModels.Document;
 using Bitmap = Avalonia.Media.Imaging.Bitmap;
 using Bitmap = Avalonia.Media.Imaging.Bitmap;
@@ -126,7 +127,7 @@ internal static class ClipboardController
 
 
         await Clipboard.SetDataObjectAsync(data);
         await Clipboard.SetDataObjectAsync(data);
     }
     }
-    
+
     public static async Task CopyVisibleToClipboard(DocumentViewModel document)
     public static async Task CopyVisibleToClipboard(DocumentViewModel document)
     {
     {
         await Clipboard.ClearAsync();
         await Clipboard.ClearAsync();
@@ -134,7 +135,7 @@ internal static class ClipboardController
         DataObject data = new DataObject();
         DataObject data = new DataObject();
 
 
         RectD copyArea = new RectD(VecD.Zero, document.SizeBindable);
         RectD copyArea = new RectD(VecD.Zero, document.SizeBindable);
-        
+
         if (!document.SelectionPathBindable.IsEmpty)
         if (!document.SelectionPathBindable.IsEmpty)
         {
         {
             copyArea = document.SelectionPathBindable.TightBounds;
             copyArea = document.SelectionPathBindable.TightBounds;
@@ -145,15 +146,16 @@ internal static class ClipboardController
         }
         }
 
 
         using Surface documentSurface = new Surface(document.SizeBindable);
         using Surface documentSurface = new Surface(document.SizeBindable);
-        
-        document.Renderer.RenderDocument(documentSurface.DrawingSurface, document.AnimationDataViewModel.ActiveFrameTime);
-        
+
+        document.Renderer.RenderDocument(documentSurface.DrawingSurface,
+            document.AnimationDataViewModel.ActiveFrameTime);
+
         Surface surfaceToCopy = new Surface((VecI)copyArea.Size.Ceiling());
         Surface surfaceToCopy = new Surface((VecI)copyArea.Size.Ceiling());
         using Paint paint = new Paint();
         using Paint paint = new Paint();
-        
+
         surfaceToCopy.DrawingSurface.Canvas.DrawImage(
         surfaceToCopy.DrawingSurface.Canvas.DrawImage(
-        documentSurface.DrawingSurface.Snapshot(),
-        copyArea, new RectD(0, 0, copyArea.Size.X, copyArea.Size.Y), paint);
+            documentSurface.DrawingSurface.Snapshot(),
+            copyArea, new RectD(0, 0, copyArea.Size.X, copyArea.Size.Y), paint);
 
 
         await AddImageToClipboard(surfaceToCopy, data);
         await AddImageToClipboard(surfaceToCopy, data);
 
 
@@ -184,9 +186,9 @@ internal static class ClipboardController
     /// </summary>
     /// </summary>
     public static bool TryPaste(DocumentViewModel document, IEnumerable<IDataObject> data, bool pasteAsNew = false)
     public static bool TryPaste(DocumentViewModel document, IEnumerable<IDataObject> data, bool pasteAsNew = false)
     {
     {
-        Guid sourceDocument = GetSourceDocument(data); 
+        Guid sourceDocument = GetSourceDocument(data);
         Guid[] layerIds = GetLayerIds(data);
         Guid[] layerIds = GetLayerIds(data);
-        
+
         if (sourceDocument != document.Id)
         if (sourceDocument != document.Id)
         {
         {
             layerIds = [];
             layerIds = [];
@@ -242,11 +244,11 @@ internal static class ClipboardController
         document.Operations.PasteImagesAsLayers(images, document.AnimationDataViewModel.ActiveFrameBindable);
         document.Operations.PasteImagesAsLayers(images, document.AnimationDataViewModel.ActiveFrameBindable);
         return true;
         return true;
     }
     }
-    
+
     private static bool AllMatchesPos(Guid[] layerIds, IEnumerable<IDataObject> data, DocumentViewModel doc)
     private static bool AllMatchesPos(Guid[] layerIds, IEnumerable<IDataObject> data, DocumentViewModel doc)
     {
     {
         var dataObjects = data as IDataObject[] ?? data.ToArray();
         var dataObjects = data as IDataObject[] ?? data.ToArray();
-        
+
         var dataObjectWithPos = dataObjects.FirstOrDefault(x => x.Contains(ClipboardDataFormats.PositionFormat));
         var dataObjectWithPos = dataObjects.FirstOrDefault(x => x.Contains(ClipboardDataFormats.PositionFormat));
         VecD pos = VecD.Zero;
         VecD pos = VecD.Zero;
 
 
@@ -254,7 +256,7 @@ internal static class ClipboardController
         {
         {
             pos = dataObjectWithPos.GetVecD(ClipboardDataFormats.PositionFormat);
             pos = dataObjectWithPos.GetVecD(ClipboardDataFormats.PositionFormat);
         }
         }
-        
+
         for (var i = 0; i < layerIds.Length; i++)
         for (var i = 0; i < layerIds.Length; i++)
         {
         {
             var layerId = layerIds[i];
             var layerId = layerIds[i];
@@ -263,7 +265,7 @@ internal static class ClipboardController
             if (layer is not { TightBounds: not null } || !layer.TightBounds.Value.Pos.AlmostEquals(pos))
             if (layer is not { TightBounds: not null } || !layer.TightBounds.Value.Pos.AlmostEquals(pos))
                 return false;
                 return false;
         }
         }
-        
+
         return true;
         return true;
     }
     }
 
 
@@ -281,7 +283,7 @@ internal static class ClipboardController
 
 
         return [];
         return [];
     }
     }
-    
+
     private static Guid GetSourceDocument(IEnumerable<IDataObject> data)
     private static Guid GetSourceDocument(IEnumerable<IDataObject> data)
     {
     {
         foreach (var dataObject in data)
         foreach (var dataObject in data)
@@ -569,4 +571,55 @@ internal static class ClipboardController
         result = null;
         result = null;
         return false;
         return false;
     }
     }
+
+    public static async Task CopyNodes(Guid[] nodeIds)
+    {
+        await Clipboard.ClearAsync();
+
+        DataObject data = new DataObject();
+
+        byte[] nodeIdsBytes = Encoding.UTF8.GetBytes(string.Join(";", nodeIds.Select(x => x.ToString())));
+
+        data.Set(ClipboardDataFormats.NodeIdList, nodeIdsBytes);
+
+        await Clipboard.SetDataObjectAsync(data);
+    }
+
+    public static async Task<List<Guid>> GetNodeIds()
+    {
+        var data = await TryGetDataObject();
+        var nodeIds = GetNodeIds(data);
+        
+        return nodeIds.ToList();
+    }
+
+    public static async Task<Guid[]> GetNodesFromClipboard()
+    {
+        var data = await TryGetDataObject();
+        return GetNodeIds(data);
+    }
+
+    private static Guid[] GetNodeIds(IEnumerable<IDataObject?> data)
+    {
+        foreach (var dataObject in data)
+        {
+            if (dataObject.Contains(ClipboardDataFormats.NodeIdList))
+            {
+                byte[] nodeIds = (byte[])dataObject.Get(ClipboardDataFormats.NodeIdList);
+                string nodeIdsString = System.Text.Encoding.UTF8.GetString(nodeIds);
+                return nodeIdsString.Split(';').Select(Guid.Parse).ToArray();
+            }
+        }
+
+        return [];
+    }
+
+    public static async Task<bool> AreNodesInClipboard()
+    {
+        var formats = await Clipboard.GetFormatsAsync();
+        if (formats == null || formats.Length == 0)
+            return false;
+        
+        return formats.Contains(ClipboardDataFormats.NodeIdList);
+    }
 }
 }

+ 1 - 1
src/PixiEditor/Models/Controllers/ShortcutController.cs

@@ -57,7 +57,7 @@ internal class ShortcutController
             return;
             return;
         }
         }
 
 
-        var commands = CommandController.Current.Commands[shortcut].Where(x => x.ShortcutContext is null || x.ShortcutContext == ActiveContext).ToList();
+        var commands = CommandController.Current.Commands[shortcut].Where(x => x.ShortcutContexts is null || x.ShortcutContexts.Contains(ActiveContext)).ToList();
 
 
         if (!commands.Any())
         if (!commands.Any())
         {
         {

+ 17 - 0
src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -837,4 +837,21 @@ internal class DocumentOperationsModule : IDocumentOperations
         Internals.ActionAccumulator.AddFinishedActions(
         Internals.ActionAccumulator.AddFinishedActions(
             new ChangeProcessingColorSpace_Action(ColorSpace.CreateSrgb()));
             new ChangeProcessingColorSpace_Action(ColorSpace.CreateSrgb()));
     }
     }
+
+    public Guid? DuplicateNode(Guid nodeId)
+    {
+        if (Internals.ChangeController.IsBlockingChangeActive)
+            return null;
+
+        Internals.ChangeController.TryStopActiveExecutor();
+        
+        if(!Document.StructureHelper.TryFindNode(nodeId, out INodeHandler node) || node.InternalName == OutputNode.UniqueName)
+            return null;
+
+        Guid newGuid = Guid.NewGuid();
+        
+        Internals.ActionAccumulator.AddFinishedActions(new DuplicateNode_Action(nodeId, newGuid));
+        
+        return newGuid;
+    }
 }
 }

+ 6 - 1
src/PixiEditor/Models/DocumentModels/Public/DocumentStructureModule.cs

@@ -26,6 +26,11 @@ internal class DocumentStructureModule
         return doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == guid && x is T) as T;
         return doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == guid && x is T) as T;
     }
     }
 
 
+    public bool TryFindNode<T>(Guid guid, out T found) where T : class, INodeHandler
+    {
+        found = FindNode<T>(guid);
+        return found != null;
+    }
 
 
     public Guid FindClosestMember(IReadOnlyList<Guid> guids)
     public Guid FindClosestMember(IReadOnlyList<Guid> guids)
     {
     {
@@ -140,7 +145,7 @@ internal class DocumentStructureModule
 
 
         return layers;
         return layers;
     }
     }
-    
+
     public List<IStructureMemberHandler> GetAllMembers()
     public List<IStructureMemberHandler> GetAllMembers()
     {
     {
         List<IStructureMemberHandler> members = new List<IStructureMemberHandler>();
         List<IStructureMemberHandler> members = new List<IStructureMemberHandler>();

+ 1 - 1
src/PixiEditor/Models/Rendering/PreviewPainter.cs

@@ -33,7 +33,7 @@ public class PreviewPainter
         Renderer = renderer;
         Renderer = renderer;
     }
     }
 
 
-    public void Paint(DrawingSurface renderOn, VecI boundsSize, Matrix3X3 matrix) 
+    public void Paint(DrawingSurface renderOn, VecI boundsSize, Matrix3X3 matrix)
     {
     {
         if (PreviewRenderable == null)
         if (PreviewRenderable == null)
         {
         {

+ 136 - 128
src/PixiEditor/Styles/Templates/NodeGraphView.axaml

@@ -7,147 +7,155 @@
         <Setter Property="Template">
         <Setter Property="Template">
             <ControlTemplate>
             <ControlTemplate>
                 <Grid Background="Transparent">
                 <Grid Background="Transparent">
+
+                    <Rectangle Name="PART_SelectionRectangle" HorizontalAlignment="Left"
+                               VerticalAlignment="Top"
+                               IsVisible="False" ZIndex="100"
+                               Fill="{DynamicResource SelectionFillBrush}" Opacity="1" />
                     <Grid.ContextFlyout>
                     <Grid.ContextFlyout>
                         <Flyout>
                         <Flyout>
                             <nodes:NodePicker
                             <nodes:NodePicker
                                 AllNodeTypeInfos="{Binding AllNodeTypeInfos, RelativeSource={RelativeSource TemplatedParent}}"
                                 AllNodeTypeInfos="{Binding AllNodeTypeInfos, RelativeSource={RelativeSource TemplatedParent}}"
                                 SearchQuery="{Binding SearchQuery, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"
                                 SearchQuery="{Binding SearchQuery, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"
-                                SelectNodeCommand="{Binding CreateNodeFromContextCommand, RelativeSource={RelativeSource TemplatedParent}}"
-                                />
+                                SelectNodeCommand="{Binding CreateNodeFromContextCommand, RelativeSource={RelativeSource TemplatedParent}}" />
                         </Flyout>
                         </Flyout>
-                        </Grid.ContextFlyout>
-                        <ItemsControl ZIndex="1" ClipToBounds="False"
-                                      Name="PART_Nodes"
-                                      ItemsSource="{Binding NodeGraph.AllNodes, RelativeSource={RelativeSource TemplatedParent}}">
-                            <ItemsControl.ItemsPanel>
-                                <ItemsPanelTemplate>
-                                    <Canvas RenderTransformOrigin="0, 0">
-                                        <Canvas.RenderTransform>
-                                            <TransformGroup>
-                                                <ScaleTransform
-                                                    ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                                    ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
-                                                <TranslateTransform
-                                                    X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                                    Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
-                                            </TransformGroup>
-                                        </Canvas.RenderTransform>
-                                    </Canvas>
-                                </ItemsPanelTemplate>
-                            </ItemsControl.ItemsPanel>
-                            <ItemsControl.ItemTemplate>
-                                <DataTemplate>
-                                    <nodes:NodeView
-                                        Node="{Binding}"
-                                        DisplayName="{Binding NodeNameBindable}"
-                                        CategoryBackgroundBrush="{Binding CategoryBackgroundBrush}"
-                                        Inputs="{Binding Inputs}"
-                                        ActiveFrame="{Binding ActiveFrame, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                        BorderBrush="{Binding InternalName, Converter={converters:NodeInternalNameToStyleConverter}, ConverterParameter='BorderBrush'}"
-                                        BorderThickness="2"
-                                        Outputs="{Binding Outputs}"
-                                        IsSelected="{Binding IsNodeSelected}"
-                                        SelectNodeCommand="{Binding SelectNodeCommand,
+                    </Grid.ContextFlyout>
+                    <ItemsControl ZIndex="1" ClipToBounds="False"
+                                  Name="PART_Nodes"
+                                  ItemsSource="{Binding NodeGraph.AllNodes, RelativeSource={RelativeSource TemplatedParent}}">
+                        <ItemsControl.ItemsPanel>
+                            <ItemsPanelTemplate>
+                                <Canvas RenderTransformOrigin="0, 0">
+                                    <Canvas.RenderTransform>
+                                        <TransformGroup>
+                                            <ScaleTransform
+                                                ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                            <TranslateTransform
+                                                X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                        </TransformGroup>
+                                    </Canvas.RenderTransform>
+                                </Canvas>
+                            </ItemsPanelTemplate>
+                        </ItemsControl.ItemsPanel>
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate>
+                                <nodes:NodeView
+                                    Node="{Binding}"
+                                    DisplayName="{Binding NodeNameBindable}"
+                                    CategoryBackgroundBrush="{Binding CategoryBackgroundBrush}"
+                                    Inputs="{Binding Inputs}"
+                                    ActiveFrame="{Binding ActiveFrame, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                    BorderBrush="{Binding InternalName, Converter={converters:NodeInternalNameToStyleConverter}, ConverterParameter='BorderBrush'}"
+                                    BorderThickness="2"
+                                    Outputs="{Binding Outputs}"
+                                    IsSelected="{Binding IsNodeSelected}"
+                                    SelectNodeCommand="{Binding SelectNodeCommand,
                                     RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
                                     RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                        StartDragCommand="{Binding StartDraggingCommand, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                        DragCommand="{Binding DraggedCommand, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                        EndDragCommand="{Binding EndDragCommand,
+                                    StartDragCommand="{Binding StartDraggingCommand, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                    DragCommand="{Binding DraggedCommand, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                    EndDragCommand="{Binding EndDragCommand,
                                         RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
                                         RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                        SocketDropCommand="{Binding SocketDropCommand,
+                                    SocketDropCommand="{Binding SocketDropCommand,
                                         RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
                                         RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                        ResultPreview="{Binding ResultPainter}" />
-                                </DataTemplate>
-                            </ItemsControl.ItemTemplate>
-                            <ItemsControl.ItemContainerTheme>
-                                <ControlTheme TargetType="ContentPresenter">
-                                    <Setter Property="Canvas.Left" Value="{Binding PositionBindable.X}" />
-                                    <Setter Property="Canvas.Top" Value="{Binding PositionBindable.Y}" />
-                                </ControlTheme>
-                            </ItemsControl.ItemContainerTheme>
-                        </ItemsControl>
-                        <ItemsControl Name="PART_Connections"
-                                      ItemsSource="{Binding NodeGraph.Connections, RelativeSource={RelativeSource TemplatedParent}}">
-                            <ItemsControl.ItemsPanel>
-                                <ItemsPanelTemplate>
-                                    <Canvas RenderTransformOrigin="0, 0">
-                                        <Canvas.RenderTransform>
-                                            <TransformGroup>
-                                                <ScaleTransform
-                                                    ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                                    ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
-                                                <TranslateTransform
-                                                    X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                                    Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
-                                            </TransformGroup>
-                                        </Canvas.RenderTransform>
-                                    </Canvas>
-                                </ItemsPanelTemplate>
-                            </ItemsControl.ItemsPanel>
-                            <ItemsControl.ItemTemplate>
-                                <DataTemplate>
-                                    <nodes:ConnectionView
-                                        InputNodePosition="{Binding InputNode.PositionBindable}"
-                                        OutputNodePosition="{Binding OutputNode.PositionBindable}"
-                                        InputProperty="{Binding InputProperty}"
-                                        OutputProperty="{Binding OutputProperty}">
-                                        <nodes:ConnectionView.IsVisible>
-                                            <MultiBinding Converter="{x:Static BoolConverters.And}">
-                                                <Binding Path="InputProperty.IsVisible" />
-                                                <Binding Path="OutputProperty.IsVisible" />
-                                            </MultiBinding>
-                                        </nodes:ConnectionView.IsVisible>
-                                    </nodes:ConnectionView>
-                                </DataTemplate>
-                            </ItemsControl.ItemTemplate>
-                        </ItemsControl>
+                                    ResultPreview="{Binding ResultPainter}" />
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                        <ItemsControl.ItemContainerTheme>
+                            <ControlTheme TargetType="ContentPresenter">
+                                <Setter Property="Canvas.Left" Value="{Binding PositionBindable.X}" />
+                                <Setter Property="Canvas.Top" Value="{Binding PositionBindable.Y}" />
+                            </ControlTheme>
+                        </ItemsControl.ItemContainerTheme>
+                    </ItemsControl>
+                    <ItemsControl Name="PART_Connections"
+                                  ItemsSource="{Binding NodeGraph.Connections, RelativeSource={RelativeSource TemplatedParent}}">
+                        <ItemsControl.ItemsPanel>
+                            <ItemsPanelTemplate>
+                                <Canvas RenderTransformOrigin="0, 0">
+                                    <Canvas.RenderTransform>
+                                        <TransformGroup>
+                                            <ScaleTransform
+                                                ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                            <TranslateTransform
+                                                X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                        </TransformGroup>
+                                    </Canvas.RenderTransform>
+                                </Canvas>
+                            </ItemsPanelTemplate>
+                        </ItemsControl.ItemsPanel>
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate>
+                                <nodes:ConnectionView
+                                    InputNodePosition="{Binding InputNode.PositionBindable}"
+                                    OutputNodePosition="{Binding OutputNode.PositionBindable}"
+                                    InputProperty="{Binding InputProperty}"
+                                    OutputProperty="{Binding OutputProperty}">
+                                    <nodes:ConnectionView.IsVisible>
+                                        <MultiBinding Converter="{x:Static BoolConverters.And}">
+                                            <Binding Path="InputProperty.IsVisible" />
+                                            <Binding Path="OutputProperty.IsVisible" />
+                                        </MultiBinding>
+                                    </nodes:ConnectionView.IsVisible>
+                                </nodes:ConnectionView>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                    </ItemsControl>
                     <ItemsControl
                     <ItemsControl
                         ZIndex="-1"
                         ZIndex="-1"
                         Name="PART_Frames"
                         Name="PART_Frames"
                         ItemsSource="{Binding NodeGraph.Frames, RelativeSource={RelativeSource TemplatedParent}}">
                         ItemsSource="{Binding NodeGraph.Frames, RelativeSource={RelativeSource TemplatedParent}}">
-                            <ItemsControl.ItemsPanel>
-                                <ItemsPanelTemplate>
-                                    <Canvas RenderTransformOrigin="0, 0">
-                                        <Canvas.RenderTransform>
-                                            <TransformGroup>
-                                                <ScaleTransform
-                                                    ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                                    ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
-                                                <TranslateTransform
-                                                    X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                                    Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
-                                            </TransformGroup>
-                                        </Canvas.RenderTransform>
-                                    </Canvas>
-                                </ItemsPanelTemplate>
-                            </ItemsControl.ItemsPanel>
-                            <ItemsControl.ItemTemplate>
-                                <DataTemplate>
-                                    <nodes:NodeFrameView
-                                        TopLeft="{Binding TopLeft}"
-                                        BottomRight="{Binding BottomRight}"
-                                        Size="{Binding Size}">
-                                        <nodes:NodeFrameView.Background>
-                                            <MultiBinding Converter="{converters:UnsetSkipMultiConverter}">
-                                                 <Binding Path="InternalName" Converter="{converters:NodeInternalNameToStyleConverter}" ConverterParameter="BackgroundBrush" />
-                                                 <DynamicResource ResourceKey="NodeFrameBackgroundBrush"/>
-                                            </MultiBinding>
-                                        </nodes:NodeFrameView.Background>
-                                        <nodes:NodeFrameView.BorderBrush>
-                                            <MultiBinding Converter="{converters:UnsetSkipMultiConverter}">
-                                                <Binding Path="InternalName" Converter="{converters:NodeInternalNameToStyleConverter}" ConverterParameter="BorderBrush" />
-                                                <DynamicResource ResourceKey="NodeFrameBorderBrush"/>
-                                            </MultiBinding>
-                                        </nodes:NodeFrameView.BorderBrush>
-                                    </nodes:NodeFrameView>
-                                </DataTemplate>
-                            </ItemsControl.ItemTemplate>
-                            <ItemsControl.ItemContainerTheme>
-                                <ControlTheme TargetType="ContentPresenter">
-                                    <Setter Property="Canvas.Left" Value="{Binding TopLeft.X}" />
-                                    <Setter Property="Canvas.Top" Value="{Binding TopLeft.Y}" />
-                                </ControlTheme>
-                            </ItemsControl.ItemContainerTheme>
-                        </ItemsControl>
+                        <ItemsControl.ItemsPanel>
+                            <ItemsPanelTemplate>
+                                <Canvas RenderTransformOrigin="0, 0">
+                                    <Canvas.RenderTransform>
+                                        <TransformGroup>
+                                            <ScaleTransform
+                                                ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                            <TranslateTransform
+                                                X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                        </TransformGroup>
+                                    </Canvas.RenderTransform>
+                                </Canvas>
+                            </ItemsPanelTemplate>
+                        </ItemsControl.ItemsPanel>
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate>
+                                <nodes:NodeFrameView
+                                    TopLeft="{Binding TopLeft}"
+                                    BottomRight="{Binding BottomRight}"
+                                    Size="{Binding Size}">
+                                    <nodes:NodeFrameView.Background>
+                                        <MultiBinding Converter="{converters:UnsetSkipMultiConverter}">
+                                            <Binding Path="InternalName"
+                                                     Converter="{converters:NodeInternalNameToStyleConverter}"
+                                                     ConverterParameter="BackgroundBrush" />
+                                            <DynamicResource ResourceKey="NodeFrameBackgroundBrush" />
+                                        </MultiBinding>
+                                    </nodes:NodeFrameView.Background>
+                                    <nodes:NodeFrameView.BorderBrush>
+                                        <MultiBinding Converter="{converters:UnsetSkipMultiConverter}">
+                                            <Binding Path="InternalName"
+                                                     Converter="{converters:NodeInternalNameToStyleConverter}"
+                                                     ConverterParameter="BorderBrush" />
+                                            <DynamicResource ResourceKey="NodeFrameBorderBrush" />
+                                        </MultiBinding>
+                                    </nodes:NodeFrameView.BorderBrush>
+                                </nodes:NodeFrameView>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                        <ItemsControl.ItemContainerTheme>
+                            <ControlTheme TargetType="ContentPresenter">
+                                <Setter Property="Canvas.Left" Value="{Binding TopLeft.X}" />
+                                <Setter Property="Canvas.Top" Value="{Binding TopLeft.Y}" />
+                            </ControlTheme>
+                        </ItemsControl.ItemContainerTheme>
+                    </ItemsControl>
                 </Grid>
                 </Grid>
             </ControlTemplate>
             </ControlTemplate>
         </Setter>
         </Setter>

+ 1 - 1
src/PixiEditor/Styles/Templates/NodeView.axaml

@@ -60,7 +60,7 @@
                                     <ImageBrush Source="/Images/CheckerTile.png"
                                     <ImageBrush Source="/Images/CheckerTile.png"
                                                 TileMode="Tile" DestinationRect="0, 0, 25, 25" />
                                                 TileMode="Tile" DestinationRect="0, 0, 25, 25" />
                                 </Panel.Background>
                                 </Panel.Background>
-                            <visuals:PreviewPainterControl
+                            <visuals:PreviewPainterControl 
                                                            PreviewPainter="{TemplateBinding ResultPreview}"
                                                            PreviewPainter="{TemplateBinding ResultPreview}"
                                                            FrameToRender="{TemplateBinding ActiveFrame}"
                                                            FrameToRender="{TemplateBinding ActiveFrame}"
                                                            RenderOptions.BitmapInterpolationMode="None">
                                                            RenderOptions.BitmapInterpolationMode="None">

+ 1 - 1
src/PixiEditor/ViewModels/Document/DocumentManagerViewModel.cs

@@ -162,7 +162,7 @@ internal class DocumentManagerViewModel : SubViewModel<ViewModelMain>, IDocument
 
 
     [Command.Basic("PixiEditor.Document.DeletePixels", "DELETE_PIXELS", "DELETE_PIXELS_DESCRIPTIVE", 
     [Command.Basic("PixiEditor.Document.DeletePixels", "DELETE_PIXELS", "DELETE_PIXELS_DESCRIPTIVE", 
         CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.Delete,
         CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.Delete,
-        ShortcutContext = typeof(ViewportWindowViewModel),
+        ShortcutContexts = [typeof(ViewportWindowViewModel)],
         Icon = PixiPerfectIcons.Eraser,
         Icon = PixiPerfectIcons.Eraser,
         MenuItemPath = "EDIT/DELETE_SELECTED_PIXELS", MenuItemOrder = 6, AnalyticsTrack = true)]
         MenuItemPath = "EDIT/DELETE_SELECTED_PIXELS", MenuItemOrder = 6, AnalyticsTrack = true)]
     public void DeletePixels()
     public void DeletePixels()

+ 2 - 2
src/PixiEditor/ViewModels/SubViewModels/AnimationsViewModel.cs

@@ -151,7 +151,7 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
 
 
     [Command.Basic("PixiEditor.Animation.ToggleOnionSkinning", "TOGGLE_ONION_SKINNING",
     [Command.Basic("PixiEditor.Animation.ToggleOnionSkinning", "TOGGLE_ONION_SKINNING",
         "TOGGLE_ONION_SKINNING_DESCRIPTIVE",
         "TOGGLE_ONION_SKINNING_DESCRIPTIVE",
-        ShortcutContext = typeof(TimelineDockViewModel), Key = Key.O, AnalyticsTrack = true)]
+        ShortcutContexts = [typeof(TimelineDockViewModel)], Key = Key.O, AnalyticsTrack = true)]
     public void ToggleOnionSkinning(bool value)
     public void ToggleOnionSkinning(bool value)
     {
     {
         if (Owner.DocumentManagerSubViewModel.ActiveDocument is null)
         if (Owner.DocumentManagerSubViewModel.ActiveDocument is null)
@@ -161,7 +161,7 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
     }
     }
 
 
     [Command.Basic("PixiEditor.Animation.DeleteCels", "DELETE_CELS", "DELETE_CELS_DESCRIPTIVE",
     [Command.Basic("PixiEditor.Animation.DeleteCels", "DELETE_CELS", "DELETE_CELS_DESCRIPTIVE",
-        ShortcutContext = typeof(TimelineDockViewModel), Key = Key.Delete, AnalyticsTrack = true)]
+        ShortcutContexts = [typeof(TimelineDockViewModel)], Key = Key.Delete, AnalyticsTrack = true)]
     public void DeleteCels()
     public void DeleteCels()
     {
     {
         var activeDocument = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var activeDocument = Owner.DocumentManagerSubViewModel.ActiveDocument;

+ 106 - 1
src/PixiEditor/ViewModels/SubViewModels/ClipboardViewModel.cs

@@ -19,6 +19,8 @@ using PixiEditor.Models.IO;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Layers;
 using Drawie.Numerics;
 using Drawie.Numerics;
 using PixiEditor.UI.Common.Fonts;
 using PixiEditor.UI.Common.Fonts;
+using PixiEditor.ViewModels.Dock;
+using PixiEditor.ViewModels.Document;
 
 
 namespace PixiEditor.ViewModels.SubViewModels;
 namespace PixiEditor.ViewModels.SubViewModels;
 #nullable enable
 #nullable enable
@@ -48,6 +50,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
 
 
     [Command.Basic("PixiEditor.Clipboard.PasteAsNewLayer", true, "PASTE_AS_NEW_LAYER", "PASTE_AS_NEW_LAYER_DESCRIPTIVE",
     [Command.Basic("PixiEditor.Clipboard.PasteAsNewLayer", true, "PASTE_AS_NEW_LAYER", "PASTE_AS_NEW_LAYER_DESCRIPTIVE",
         CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Control,
         CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Control,
+        ShortcutContexts = [typeof(ViewportWindowViewModel), typeof(LayersDockViewModel)],
         Icon = PixiPerfectIcons.PasteAsNewLayer, AnalyticsTrack = true)]
         Icon = PixiPerfectIcons.PasteAsNewLayer, AnalyticsTrack = true)]
     [Command.Basic("PixiEditor.Clipboard.Paste", false, "PASTE", "PASTE_DESCRIPTIVE",
     [Command.Basic("PixiEditor.Clipboard.Paste", false, "PASTE", "PASTE_DESCRIPTIVE",
         CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Shift,
         CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Shift,
@@ -145,8 +148,57 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
+    [Command.Basic("PixiEditor.Clipboard.PasteNodes", "PASTE_NODES", "PASTE_NODES_DESCRIPTIVE",
+        ShortcutContexts = [typeof(NodeGraphDockViewModel)], Key = Key.V, Modifiers = KeyModifiers.Control,
+        CanExecute = "PixiEditor.Clipboard.CanPasteNodes", Icon = PixiPerfectIcons.Paste, AnalyticsTrack = true)]
+    public async Task PasteNodes()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        List<Guid> toDuplicate = await ClipboardController.GetNodeIds();
+
+        List<Guid> newIds = new();
+
+        Dictionary<Guid, Guid> nodeMapping = new();
+
+        foreach (var nodeId in toDuplicate)
+        {
+            Guid? newId = doc.Operations.DuplicateNode(nodeId);
+            if (newId != null)
+            {
+                newIds.Add(newId.Value);
+                nodeMapping.Add(nodeId, newId.Value);
+            }
+        }
+
+        if (newIds.Count == 0)
+            return;
+        
+        doc.Operations.InvokeCustomAction(() =>
+        {
+            ConnectRelatedNodes(doc, nodeMapping);
+            foreach (var node in doc.NodeGraph.AllNodes)
+            {
+                node.IsNodeSelected = false;
+            }
+
+            foreach (var node in newIds)
+            {
+                var nodeInstance = doc.NodeGraph.AllNodes.FirstOrDefault(x => x.Id == node);
+                if (nodeInstance != null)
+                {
+                    nodeInstance.IsNodeSelected = true;
+                }
+            }
+        });
+    }
+
+
     [Command.Basic("PixiEditor.Clipboard.Copy", "COPY", "COPY_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanCopy",
     [Command.Basic("PixiEditor.Clipboard.Copy", "COPY", "COPY_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanCopy",
         Key = Key.C, Modifiers = KeyModifiers.Control,
         Key = Key.C, Modifiers = KeyModifiers.Control,
+        ShortcutContexts = [typeof(ViewportWindowViewModel), typeof(LayersDockViewModel)],
         MenuItemPath = "EDIT/COPY", MenuItemOrder = 3, Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
         MenuItemPath = "EDIT/COPY", MenuItemOrder = 3, Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
     public async Task Copy()
     public async Task Copy()
     {
     {
@@ -157,7 +209,8 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         await ClipboardController.CopyToClipboard(doc);
         await ClipboardController.CopyToClipboard(doc);
     }
     }
 
 
-    [Command.Basic("PixiEditor.Clipboard.CopyVisible", "COPY_VISIBLE", "COPY_VISIBLE_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanCopy",
+    [Command.Basic("PixiEditor.Clipboard.CopyVisible", "COPY_VISIBLE", "COPY_VISIBLE_DESCRIPTIVE",
+        CanExecute = "PixiEditor.Clipboard.CanCopy",
         Key = Key.C, Modifiers = KeyModifiers.Shift,
         Key = Key.C, Modifiers = KeyModifiers.Shift,
         MenuItemPath = "EDIT/COPY_VISIBLE", MenuItemOrder = 3, Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
         MenuItemPath = "EDIT/COPY_VISIBLE", MenuItemOrder = 3, Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
     public async Task CopyVisible()
     public async Task CopyVisible()
@@ -169,6 +222,24 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         await ClipboardController.CopyVisibleToClipboard(doc);
         await ClipboardController.CopyVisibleToClipboard(doc);
     }
     }
 
 
+    [Command.Basic("PixiEditor.Clipboard.CopyNodes", "COPY_NODES", "COPY_NODES_DESCRIPTIVE",
+        Key = Key.C, Modifiers = KeyModifiers.Control,
+        ShortcutContexts = [typeof(NodeGraphDockViewModel)],
+        Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
+    public async Task CopySelectedNodes()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        var selectedNodes = doc.NodeGraph.AllNodes.Where(x => x.IsNodeSelected).Select(x => x.Id).ToArray();
+        if (selectedNodes.Length == 0)
+            return;
+
+        await ClipboardController.CopyNodes(selectedNodes);
+    }
+
+
     [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsHex", CopyColor.PrimaryHEX, "COPY_COLOR_HEX",
     [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsHex", CopyColor.PrimaryHEX, "COPY_COLOR_HEX",
         "COPY_COLOR_HEX_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon", AnalyticsTrack = true)]
         "COPY_COLOR_HEX_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon", AnalyticsTrack = true)]
     [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsRgb", CopyColor.PrimaryRGB, "COPY_COLOR_RGB",
     [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsRgb", CopyColor.PrimaryRGB, "COPY_COLOR_RGB",
@@ -210,6 +281,12 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
             : ClipboardController.IsImageInClipboard().Result;
             : ClipboardController.IsImageInClipboard().Result;
     }
     }
 
 
+    [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteNodes")]
+    public bool CanPasteNodes()
+    {
+        return Owner.DocumentIsNotNull(null) && ClipboardController.AreNodesInClipboard().Result;
+    }
+
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteColor")]
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteColor")]
     public static async Task<bool> CanPasteColor() =>
     public static async Task<bool> CanPasteColor() =>
         ColorHelper.ParseAnyFormat((await ClipboardController.Clipboard.GetTextAsync())?.Trim() ?? string.Empty, out _);
         ColorHelper.ParseAnyFormat((await ClipboardController.Clipboard.GetTextAsync())?.Trim() ?? string.Empty, out _);
@@ -264,6 +341,34 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         return ColorSearchResult.GetIcon(targetColor.ToOpaqueMediaColor().ToOpaqueColor());
         return ColorSearchResult.GetIcon(targetColor.ToOpaqueMediaColor().ToOpaqueColor());
     }
     }
 
 
+    private void ConnectRelatedNodes(DocumentViewModel doc, Dictionary<Guid, Guid> nodeMapping)
+    {
+        foreach (var connection in doc.NodeGraph.Connections)
+        {
+            if (nodeMapping.TryGetValue(connection.InputNode.Id, out var inputNode) &&
+                nodeMapping.TryGetValue(connection.OutputNode.Id, out var outputNode))
+            {
+                var inputNodeInstance = doc.NodeGraph.AllNodes.FirstOrDefault(x => x.Id == inputNode);
+                var outputNodeInstance = doc.NodeGraph.AllNodes.FirstOrDefault(x => x.Id == outputNode);
+
+                if (inputNodeInstance == null || outputNodeInstance == null)
+                    continue;
+
+                var inputProperty =
+                    inputNodeInstance.Inputs.FirstOrDefault(
+                        x => x.PropertyName == connection.InputProperty.PropertyName);
+                var outputProperty =
+                    outputNodeInstance.Outputs.FirstOrDefault(x =>
+                        x.PropertyName == connection.OutputProperty.PropertyName);
+
+                if (inputProperty == null || outputProperty == null)
+                    continue;
+
+                doc.NodeGraph.ConnectProperties(inputProperty, outputProperty);
+            }
+        }
+    }
+
     public enum CopyColor
     public enum CopyColor
     {
     {
         PrimaryHEX,
         PrimaryHEX,

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/LayersViewModel.cs

@@ -92,7 +92,7 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
     [Command.Basic("PixiEditor.Layer.DeleteAllSelected", "LAYER_DELETE_ALL_SELECTED",
     [Command.Basic("PixiEditor.Layer.DeleteAllSelected", "LAYER_DELETE_ALL_SELECTED",
         "LAYER_DELETE_ALL_SELECTED_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasSelectedMembers",
         "LAYER_DELETE_ALL_SELECTED_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasSelectedMembers",
         Icon = PixiPerfectIcons.Trash, AnalyticsTrack = true, Key = Key.Delete,
         Icon = PixiPerfectIcons.Trash, AnalyticsTrack = true, Key = Key.Delete,
-        ShortcutContext = typeof(LayersDockViewModel))]
+        ShortcutContexts = [typeof(LayersDockViewModel)])]
     public void DeleteAllSelected()
     public void DeleteAllSelected()
     {
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/NodeGraphManagerViewModel.cs

@@ -15,7 +15,7 @@ internal class NodeGraphManagerViewModel : SubViewModel<ViewModelMain>
     }
     }
 
 
     [Command.Basic("PixiEditor.NodeGraph.DeleteSelectedNodes", "DELETE_NODES", "DELETE_NODES_DESCRIPTIVE", 
     [Command.Basic("PixiEditor.NodeGraph.DeleteSelectedNodes", "DELETE_NODES", "DELETE_NODES_DESCRIPTIVE", 
-        Key = Key.Delete, ShortcutContext = typeof(NodeGraphDockViewModel), AnalyticsTrack = true)]
+        Key = Key.Delete, ShortcutContexts = [typeof(NodeGraphDockViewModel)], AnalyticsTrack = true)]
     public void DeleteSelectedNodes()
     public void DeleteSelectedNodes()
     {
     {
         var nodes = Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.AllNodes
         var nodes = Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.AllNodes

+ 4 - 4
src/PixiEditor/ViewModels/SubViewModels/SelectionViewModel.cs

@@ -65,13 +65,13 @@ internal class SelectionViewModel : SubViewModel<ViewModelMain>
     }
     }
 
 
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectLeft", "NUDGE_SELECTED_LEFT", "NUDGE_SELECTED_LEFT", Key = Key.Left, Parameter = new int[] { -1, 0 }, Icon = PixiPerfectIcons.ChevronLeft, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectLeft", "NUDGE_SELECTED_LEFT", "NUDGE_SELECTED_LEFT", Key = Key.Left, Parameter = new int[] { -1, 0 }, Icon = PixiPerfectIcons.ChevronLeft, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
-        ShortcutContext = typeof(ViewportWindowViewModel))]
+        ShortcutContexts = [typeof(ViewportWindowViewModel)])]
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectRight", "NUDGE_SELECTED_RIGHT", "NUDGE_SELECTED_RIGHT", Key = Key.Right, Parameter = new int[] { 1, 0 }, Icon = PixiPerfectIcons.ChevronRight, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectRight", "NUDGE_SELECTED_RIGHT", "NUDGE_SELECTED_RIGHT", Key = Key.Right, Parameter = new int[] { 1, 0 }, Icon = PixiPerfectIcons.ChevronRight, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
-        ShortcutContext = typeof(ViewportWindowViewModel))]
+        ShortcutContexts = [typeof(ViewportWindowViewModel)])]
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectUp", "NUDGE_SELECTED_UP", "NUDGE_SELECTED_UP", Key = Key.Up, Parameter = new int[] { 0, -1 }, Icon = PixiPerfectIcons.ChevronUp, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectUp", "NUDGE_SELECTED_UP", "NUDGE_SELECTED_UP", Key = Key.Up, Parameter = new int[] { 0, -1 }, Icon = PixiPerfectIcons.ChevronUp, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
-        ShortcutContext = typeof(ViewportWindowViewModel))]
+        ShortcutContexts = [typeof(ViewportWindowViewModel)])]
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectDown", "NUDGE_SELECTED_DOWN", "NUDGE_SELECTED_DOWN", Key = Key.Down, Parameter = new int[] { 0, 1 }, Icon = PixiPerfectIcons.ChevronDown, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
     [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectDown", "NUDGE_SELECTED_DOWN", "NUDGE_SELECTED_DOWN", Key = Key.Down, Parameter = new int[] { 0, 1 }, Icon = PixiPerfectIcons.ChevronDown, CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject",
-        ShortcutContext = typeof(ViewportWindowViewModel))]
+        ShortcutContexts = [typeof(ViewportWindowViewModel)])]
     public void NudgeSelectedObject(int[] dist)
     public void NudgeSelectedObject(int[] dist)
     {
     {
         VecI distance = new(dist[0], dist[1]);
         VecI distance = new(dist[0], dist[1]);

+ 112 - 10
src/PixiEditor/Views/Nodes/NodeGraphView.cs

@@ -3,13 +3,16 @@ using System.Collections.Specialized;
 using System.Windows.Input;
 using System.Windows.Input;
 using Avalonia;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls;
+using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
 using Avalonia.Input;
 using Avalonia.Input;
 using Avalonia.Media;
 using Avalonia.Media;
 using Avalonia.Threading;
 using Avalonia.Threading;
 using Avalonia.VisualTree;
 using Avalonia.VisualTree;
 using CommunityToolkit.Mvvm.Input;
 using CommunityToolkit.Mvvm.Input;
+using Drawie.Backend.Core.Numerics;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
 using PixiEditor.ViewModels.Document;
 using PixiEditor.ViewModels.Document;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
@@ -22,6 +25,7 @@ using Point = Avalonia.Point;
 
 
 namespace PixiEditor.Views.Nodes;
 namespace PixiEditor.Views.Nodes;
 
 
+[TemplatePart("PART_SelectionRectangle", typeof(Rectangle))]
 internal class NodeGraphView : Zoombox.Zoombox
 internal class NodeGraphView : Zoombox.Zoombox
 {
 {
     public static readonly StyledProperty<INodeGraphHandler> NodeGraphProperty =
     public static readonly StyledProperty<INodeGraphHandler> NodeGraphProperty =
@@ -190,9 +194,17 @@ internal class NodeGraphView : Zoombox.Zoombox
     private NodeConnectionViewModel? _hiddenConnection;
     private NodeConnectionViewModel? _hiddenConnection;
     private Color _startingPropColor;
     private Color _startingPropColor;
     private VecD _lastMouseClickPos;
     private VecD _lastMouseClickPos;
+    private Point _lastMousePos;
 
 
     private ItemsControl nodeItemsControl;
     private ItemsControl nodeItemsControl;
     private ItemsControl connectionItemsControl;
     private ItemsControl connectionItemsControl;
+    private Rectangle selectionRectangle;
+    
+    private List<INodeHandler> selectedNodesOnStartDrag = new();
+
+    private List<Control> nodeViewsCache = new();
+
+    private bool isSelecting;
 
 
     public static readonly StyledProperty<int> ActiveFrameProperty =
     public static readonly StyledProperty<int> ActiveFrameProperty =
         AvaloniaProperty.Register<NodeGraphView, int>("ActiveFrame");
         AvaloniaProperty.Register<NodeGraphView, int>("ActiveFrame");
@@ -215,14 +227,16 @@ internal class NodeGraphView : Zoombox.Zoombox
         base.OnApplyTemplate(e);
         base.OnApplyTemplate(e);
         nodeItemsControl = e.NameScope.Find<ItemsControl>("PART_Nodes");
         nodeItemsControl = e.NameScope.Find<ItemsControl>("PART_Nodes");
         connectionItemsControl = e.NameScope.Find<ItemsControl>("PART_Connections");
         connectionItemsControl = e.NameScope.Find<ItemsControl>("PART_Connections");
+        selectionRectangle = e.NameScope.Find<Rectangle>("PART_SelectionRectangle");
 
 
         Dispatcher.UIThread.Post(() =>
         Dispatcher.UIThread.Post(() =>
         {
         {
-            nodeItemsControl.ItemsPanelRoot.Children.CollectionChanged += Items_CollectionChanged;
+            nodeItemsControl.ItemsPanelRoot.Children.CollectionChanged += NodeItems_CollectionChanged;
+            nodeViewsCache = nodeItemsControl.ItemsPanelRoot.Children.ToList();
         });
         });
     }
     }
 
 
-    private void Items_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+    private void NodeItems_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
     {
     {
         if (e.Action == NotifyCollectionChangedAction.Add)
         if (e.Action == NotifyCollectionChangedAction.Add)
         {
         {
@@ -233,6 +247,7 @@ internal class NodeGraphView : Zoombox.Zoombox
                     continue;
                     continue;
                 }
                 }
 
 
+                nodeViewsCache.Add(presenter);
                 if (presenter.Child == null)
                 if (presenter.Child == null)
                 {
                 {
                     presenter.PropertyChanged += OnPresenterPropertyChanged;
                     presenter.PropertyChanged += OnPresenterPropertyChanged;
@@ -243,8 +258,34 @@ internal class NodeGraphView : Zoombox.Zoombox
                 nodeView.PropertyChanged += NodeView_PropertyChanged;
                 nodeView.PropertyChanged += NodeView_PropertyChanged;
             }
             }
         }
         }
-    }
+        else if (e.Action == NotifyCollectionChangedAction.Remove)
+        {
+            foreach (Control control in e.OldItems)
+            {
+                if (control is not ContentPresenter presenter)
+                {
+                    continue;
+                }
+
+                nodeViewsCache.Remove(presenter);
 
 
+                if (presenter.Child == null)
+                {
+                    presenter.PropertyChanged -= OnPresenterPropertyChanged;
+                    continue;
+                }
+
+
+                NodeView nodeView = (NodeView)presenter.Child;
+                nodeView.PropertyChanged -= NodeView_PropertyChanged;
+            }
+        }
+        else if (e.Action == NotifyCollectionChangedAction.Reset)
+        {
+            nodeViewsCache.Clear();
+        }
+    }
+    
     private void OnPresenterPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
     private void OnPresenterPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
     {
     {
         if (e.Property == ContentPresenter.ChildProperty)
         if (e.Property == ContentPresenter.ChildProperty)
@@ -254,16 +295,16 @@ internal class NodeGraphView : Zoombox.Zoombox
                 nodeView.PropertyChanged += NodeView_PropertyChanged;
                 nodeView.PropertyChanged += NodeView_PropertyChanged;
             }
             }
         }
         }
-        
-        if(e.Property == Canvas.LeftProperty || e.Property == Canvas.TopProperty)
+
+        if (e.Property == Canvas.LeftProperty || e.Property == Canvas.TopProperty)
         {
         {
             if (e.Sender is ContentPresenter presenter && presenter.Child is NodeView nodeView)
             if (e.Sender is ContentPresenter presenter && presenter.Child is NodeView nodeView)
             {
             {
                 Dispatcher.UIThread.Post(
                 Dispatcher.UIThread.Post(
                     () =>
                     () =>
-                {
-                    UpdateConnections(nodeView);
-                }, DispatcherPriority.Render);
+                    {
+                        UpdateConnections(nodeView);
+                    }, DispatcherPriority.Render);
             }
             }
         }
         }
     }
     }
@@ -285,9 +326,18 @@ internal class NodeGraphView : Zoombox.Zoombox
         if (e.GetMouseButton(this) == MouseButton.Left)
         if (e.GetMouseButton(this) == MouseButton.Left)
         {
         {
             ClearSelection();
             ClearSelection();
+            isSelecting = true;
+            selectionRectangle.IsVisible = true;
+            e.Handled = true;
+        }
+        else
+        {
+            isSelecting = false;
+            selectionRectangle.IsVisible = false;
         }
         }
 
 
         Point pos = e.GetPosition(this);
         Point pos = e.GetPosition(this);
+        _lastMousePos = pos;
         _lastMouseClickPos = ToZoomboxSpace(new VecD(pos.X, pos.Y));
         _lastMouseClickPos = ToZoomboxSpace(new VecD(pos.X, pos.Y));
     }
     }
 
 
@@ -297,6 +347,35 @@ internal class NodeGraphView : Zoombox.Zoombox
         {
         {
             UpdateConnectionEnd(e);
             UpdateConnectionEnd(e);
         }
         }
+        else if (isSelecting)
+        {
+            var pos = e.GetPosition(this);
+            Point currentPoint = new Point(pos.X, pos.Y);
+
+            float x = (float)Math.Min(_lastMousePos.X, currentPoint.X);
+            float y = (float)Math.Min(_lastMousePos.Y, currentPoint.Y);
+            float width = (float)Math.Abs(_lastMousePos.X - currentPoint.X);
+            float height = (float)Math.Abs(_lastMousePos.Y - currentPoint.Y);
+
+            selectionRectangle.Width = width;
+            selectionRectangle.Height = height;
+            Thickness margin = new Thickness(x, y, 0, 0);
+
+            selectionRectangle.Margin = margin;
+
+
+            VecD zoomboxSpacePos = ToZoomboxSpace(new VecD(x, y));
+            VecD zoomboxSpaceSize = ToZoomboxSpace(new VecD(x + width, y + height));
+
+            x = (float)zoomboxSpacePos.X;
+            y = (float)zoomboxSpacePos.Y;
+            width = (float)(zoomboxSpaceSize.X - zoomboxSpacePos.X);
+            height = (float)(zoomboxSpaceSize.Y - zoomboxSpacePos.Y);
+
+            Rect zoomboxSpaceRect = new Rect(x, y, width, height);
+            ClearSelection();
+            SelectWithinBounds(zoomboxSpaceRect);
+        }
     }
     }
 
 
     private void UpdateConnectionEnd(PointerEventArgs e)
     private void UpdateConnectionEnd(PointerEventArgs e)
@@ -347,6 +426,20 @@ internal class NodeGraphView : Zoombox.Zoombox
         }
         }
     }
     }
 
 
+    private void SelectWithinBounds(Rect rect)
+    {
+        foreach (var control in nodeViewsCache)
+        {
+            if (control.Bounds.Intersects(rect))
+            {
+                if (control is ContentPresenter { Child: NodeView nodeView })
+                {
+                    nodeView.Node.IsNodeSelected = true;
+                }
+            }
+        }
+    }
+
     private static Color? GetSocketColor(NodeSocket? nodeSocket)
     private static Color? GetSocketColor(NodeSocket? nodeSocket)
     {
     {
         if (nodeSocket == null)
         if (nodeSocket == null)
@@ -386,6 +479,14 @@ internal class NodeGraphView : Zoombox.Zoombox
             _hiddenConnection = null;
             _hiddenConnection = null;
         }
         }
 
 
+        if (isSelecting)
+        {
+            isSelecting = false;
+            selectionRectangle.IsVisible = false;
+            selectionRectangle.Width = 0;
+            selectionRectangle.Height = 0;
+        }
+
         if (e.Source is NodeView nodeView)
         if (e.Source is NodeView nodeView)
         {
         {
             UpdateConnections(nodeView);
             UpdateConnections(nodeView);
@@ -427,7 +528,8 @@ internal class NodeGraphView : Zoombox.Zoombox
                 isDraggingNodes = true;
                 isDraggingNodes = true;
                 Point pt = e.GetPosition(this);
                 Point pt = e.GetPosition(this);
                 clickPointOffset = ToZoomboxSpace(new VecD(pt.X, pt.Y));
                 clickPointOffset = ToZoomboxSpace(new VecD(pt.X, pt.Y));
-                initialNodePositions = SelectedNodes.Select(x => x.PositionBindable).ToList();
+                selectedNodesOnStartDrag = SelectedNodes.ToList();
+                initialNodePositions = selectedNodesOnStartDrag.Select(x => x.PositionBindable).ToList();
             }
             }
         }
         }
     }
     }
@@ -505,7 +607,7 @@ internal class NodeGraphView : Zoombox.Zoombox
             VecD currentPoint = ToZoomboxSpace(new VecD(pos.X, pos.Y));
             VecD currentPoint = ToZoomboxSpace(new VecD(pos.X, pos.Y));
 
 
             VecD delta = currentPoint - clickPointOffset;
             VecD delta = currentPoint - clickPointOffset;
-            ChangeNodePosCommand?.Execute((SelectedNodes, initialNodePositions[0] + delta));
+            ChangeNodePosCommand?.Execute((selectedNodesOnStartDrag, initialNodePositions[0] + delta));
         }
         }
     }
     }
 
 

+ 1 - 1
src/PixiEditor/Views/Shortcuts/ShortcutBox.cs

@@ -55,7 +55,7 @@ internal class ShortcutBox : ContentControl
 
 
         if (e != KeyCombination.None)
         if (e != KeyCombination.None)
         {
         {
-            if (controller.Commands[e].Where(x => x.ShortcutContext == null || x.ShortcutContext == Command.ShortcutContext)
+            if (controller.Commands[e].Where(x => x.ShortcutContexts == null || x.ShortcutContexts == Command.ShortcutContexts)
                     .SkipWhile(x => x == Command).FirstOrDefault() is { } oldCommand)
                     .SkipWhile(x => x == Command).FirstOrDefault() is { } oldCommand)
             {
             {
                 var oldShortcut = Command.Shortcut;
                 var oldShortcut = Command.Shortcut;