Browse Source

Merge pull request #970 from PixiEditor/some-nodes

Evaluate Path Node
Krzysztof Krysiński 2 months ago
parent
commit
2631c7a1a4
45 changed files with 800 additions and 91 deletions
  1. 1 1
      src/Drawie
  2. 62 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Calculations/RemapNode.cs
  3. 8 3
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs
  4. 51 13
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs
  5. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/EllipseVectorData.cs
  6. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/RectangleVectorData.cs
  7. 79 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/EvaluatePathNode.cs
  8. 1 1
      src/PixiEditor.ChangeableDocument/Changes/Drawing/TransformSelected_UpdateableChange.cs
  9. 5 0
      src/PixiEditor.SVG/Elements/SvgPolyline.cs
  10. BIN
      src/PixiEditor.UI.Common/Fonts/PixiPerfect.ttf
  11. 4 0
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml
  12. 4 0
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml.cs
  13. 4 0
      src/PixiEditor.UI.Common/Fonts/defs.svg
  14. 14 5
      src/PixiEditor/Data/Localization/Languages/en.json
  15. 176 0
      src/PixiEditor/Helpers/Extensions/KeyExtensions.cs
  16. 1 1
      src/PixiEditor/Models/Commands/XAML/Command.cs
  17. 61 8
      src/PixiEditor/Models/Commands/XAML/NativeMenu.cs
  18. 3 0
      src/PixiEditor/Models/Controllers/ClipboardController.cs
  19. 8 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorPathToolExecutor.cs
  20. 29 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorTextToolExecutor.cs
  21. 2 2
      src/PixiEditor/Models/Files/VideoFileType.cs
  22. 1 1
      src/PixiEditor/Models/Handlers/IAnimationHandler.cs
  23. 1 0
      src/PixiEditor/Models/Handlers/ITextOverlayHandler.cs
  24. 3 3
      src/PixiEditor/Models/IO/Exporter.cs
  25. 1 1
      src/PixiEditor/Models/Rendering/SceneRenderer.cs
  26. 2 2
      src/PixiEditor/Styles/Templates/NodeGraphView.axaml
  27. 1 1
      src/PixiEditor/Styles/Templates/Timeline.axaml
  28. 6 6
      src/PixiEditor/ViewModels/Document/AnimationDataViewModel.cs
  29. 8 9
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  30. 9 0
      src/PixiEditor/ViewModels/Document/Nodes/Calculations/RemapNodeViewModel.cs
  31. 10 0
      src/PixiEditor/ViewModels/Document/Nodes/Shapes/EvaluatePathNodeViewModel.cs
  32. 8 0
      src/PixiEditor/ViewModels/Document/TransformOverlays/TextOverlayViewModel.cs
  33. 53 7
      src/PixiEditor/ViewModels/SubViewModels/ClipboardViewModel.cs
  34. 2 0
      src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs
  35. 1 0
      src/PixiEditor/ViewModels/Tools/Tools/TextToolViewModel.cs
  36. 2 1
      src/PixiEditor/Views/Animations/KeyFrame.cs
  37. 64 6
      src/PixiEditor/Views/Animations/Timeline.cs
  38. 1 1
      src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs
  39. 3 3
      src/PixiEditor/Views/Main/ActionDisplayBar.axaml
  40. 6 0
      src/PixiEditor/Views/Main/ViewportControls/ViewportOverlays.cs
  41. 26 0
      src/PixiEditor/Views/Nodes/NodeGraphView.cs
  42. 1 0
      src/PixiEditor/Views/Overlays/Handles/ControlPointHandle.cs
  43. 42 6
      src/PixiEditor/Views/Overlays/PathOverlay/VectorPathOverlay.cs
  44. 28 1
      src/PixiEditor/Views/Overlays/TextOverlay/TextOverlay.cs
  45. 6 6
      src/PixiEditor/Views/Overlays/TransformOverlay/TransformOverlay.cs

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit 34ce7ab4d8e14f5dbad86dea076fe4c7cda15da3
+Subproject commit 7a2e6d528b0b2c2a4353291170f19e61444eae4e

+ 62 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Calculations/RemapNode.cs

@@ -0,0 +1,62 @@
+using Drawie.Backend.Core.Shaders;
+using Drawie.Backend.Core.Shaders.Generation.Expressions;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Context;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Calculations;
+
+[NodeInfo("Remap")]
+public class RemapNode : Node
+{
+    public FuncInputProperty<Float1> OldMin { get; }
+    public FuncInputProperty<Float1> OldMax { get; }
+    public FuncInputProperty<Float1> NewMin { get; }
+    public FuncInputProperty<Float1> NewMax { get; }
+
+    public FuncInputProperty<Float1> Value { get; }
+
+    public FuncOutputProperty<Float1> Result { get; }
+
+    public RemapNode()
+    {
+        OldMin = CreateFuncInput<Float1>("OldMin", "OLD_MIN", 0.0);
+        OldMax = CreateFuncInput<Float1>("OldMax", "OLD_MAX", 1.0);
+        NewMin = CreateFuncInput<Float1>("NewMin", "NEW_MIN", 0.0);
+        NewMax = CreateFuncInput<Float1>("NewMax", "NEW_MAX", 1.0);
+        Value = CreateFuncInput<Float1>("Value", "VALUE", 0.5);
+
+        Result = CreateFuncOutput<Float1>("Result", "RESULT", Remap);
+    }
+
+    private Float1 Remap(FuncContext context)
+    {
+        if (context.HasContext)
+        {
+            var oldMin = context.GetValue(OldMin);
+            var oldMax = context.GetValue(OldMax);
+            var newMin = context.GetValue(NewMin);
+            var newMax = context.GetValue(NewMax);
+            var value = context.GetValue(Value);
+
+            return context.NewFloat1(context.Builder.Functions.GetRemap(value, oldMin, oldMax, newMin, newMax));
+        }
+
+        double oldMinValue = context.GetValue(OldMin).GetConstant() as double? ?? 0.0;
+        double oldMaxValue = context.GetValue(OldMax).GetConstant() as double? ?? 1.0;
+        double newMinValue = context.GetValue(NewMin).GetConstant() as double? ?? 0.0;
+        double newMaxValue = context.GetValue(NewMax).GetConstant() as double? ?? 1.0;
+        double valueValue = context.GetValue(Value).GetConstant() as double? ?? 0.5;
+        double resultValue = newMinValue + (valueValue - oldMinValue) * (newMaxValue - newMinValue) / (oldMaxValue - oldMinValue);
+        return new Float1(string.Empty) { ConstantValue = resultValue };
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+
+    }
+
+    public override Node CreateCopy()
+    {
+        return new RemapNode();
+    }
+}

+ 8 - 3
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs

@@ -57,6 +57,7 @@ public class CreateImageNode : Node, IPreviewRenderable
     private Texture Render(RenderContext context)
     {
         var surface = textureCache.RequestTexture(0, (VecI)(Size.Value * context.ChunkResolution.Multiplier()), context.ProcessingColorSpace, false);
+        surface.DrawingSurface.Canvas.SetMatrix(Matrix3X3.Identity);
 
         if (Fill.Value is ColorPaintable colorPaintable)
         {
@@ -72,11 +73,15 @@ public class CreateImageNode : Node, IPreviewRenderable
         int saved = surface.DrawingSurface.Canvas.Save();
 
         RenderContext ctx = new RenderContext(surface.DrawingSurface, context.FrameTime, context.ChunkResolution,
-            context.RenderOutputSize, context.DocumentSize, context.ProcessingColorSpace);
+            surface.Size, context.DocumentSize, context.ProcessingColorSpace);
+        ctx.FullRerender = context.FullRerender;
+        ctx.TargetOutput = context.TargetOutput;
 
-        surface.DrawingSurface.Canvas.SetMatrix(surface.DrawingSurface.Canvas.TotalMatrix.Concat(ContentMatrix.Value));
+        float chunkMultiplier = (float)context.ChunkResolution.Multiplier();
 
-        surface.DrawingSurface.Canvas.Scale((float)context.ChunkResolution.Multiplier());
+        surface.DrawingSurface.Canvas.SetMatrix(
+            surface.DrawingSurface.Canvas.TotalMatrix.Concat(
+                Matrix3X3.CreateScale(chunkMultiplier, chunkMultiplier).Concat(ContentMatrix.Value)));
 
         Content.Value?.Paint(ctx, surface.DrawingSurface);
 

+ 51 - 13
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs

@@ -89,7 +89,9 @@ public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
         Uniforms uniforms;
         uniforms = new Uniforms();
 
-        uniforms.Add("iResolution", new Uniform("iResolution", (VecD)context.RenderOutputSize));
+        VecI finalSize = (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier());
+
+        uniforms.Add("iResolution", new Uniform("iResolution", (VecD)finalSize));
         uniforms.Add("iNormalizedTime", new Uniform("iNormalizedTime", (float)context.FrameTime.NormalizedTime));
         uniforms.Add("iFrame", new Uniform("iFrame", context.FrameTime.Frame));
 
@@ -102,8 +104,14 @@ public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
             return uniforms;
         }
 
-        Texture texture = RequestTexture(50, context.RenderOutputSize, context.ProcessingColorSpace);
-        Background.Value.Paint(context, texture.DrawingSurface);
+        Texture texture = RequestTexture(50, finalSize, context.ProcessingColorSpace);
+        int saved = texture.DrawingSurface.Canvas.Save();
+        //texture.DrawingSurface.Canvas.Scale((float)context.ChunkResolution.Multiplier(), (float)context.ChunkResolution.Multiplier());
+
+        var ctx = new RenderContext(texture.DrawingSurface, context.FrameTime, ChunkResolution.Full, finalSize,
+            context.DocumentSize, context.ProcessingColorSpace, context.Opacity);
+        Background.Value.Paint(ctx, texture.DrawingSurface);
+        texture.DrawingSurface.Canvas.RestoreToCount(saved);
 
         var snapshot = texture.DrawingSurface.Snapshot();
         lastImageShader?.Dispose();
@@ -112,6 +120,7 @@ public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
         uniforms.Add("iImage", new Uniform("iImage", lastImageShader));
 
         snapshot.Dispose();
+        //texture.Dispose();
         return uniforms;
     }
 
@@ -125,25 +134,54 @@ public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
 
         DrawingSurface targetSurface = surface;
 
-        if (ColorSpace.Value != ColorSpaceType.Inherit)
+        float width = (float)(context.RenderOutputSize.X);
+        float height = (float)(context.RenderOutputSize.Y);
+        bool scale = false;
+
+        if (context.ChunkResolution != ChunkResolution.Full)
         {
-            if (ColorSpace.Value == ColorSpaceType.Srgb && !context.ProcessingColorSpace.IsSrgb)
-            {
-                targetSurface = RequestTexture(51, context.RenderOutputSize,
-                    Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgb()).DrawingSurface;
-            }
-            else if (ColorSpace.Value == ColorSpaceType.LinearSrgb && context.ProcessingColorSpace.IsSrgb)
+            var intermediateSurface = RequestTexture(51,
+                (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier()),
+                ColorSpace.Value == ColorSpaceType.Inherit
+                    ? context.ProcessingColorSpace
+                    : ColorSpace.Value == ColorSpaceType.Srgb
+                        ? Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgb()
+                        : Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgbLinear());
+            targetSurface = intermediateSurface.DrawingSurface;
+            width = (float)(context.RenderOutputSize.X * context.ChunkResolution.InvertedMultiplier());
+            height = (float)(context.RenderOutputSize.Y * context.ChunkResolution.InvertedMultiplier());
+            scale = true;
+        }
+        else
+        {
+            if (ColorSpace.Value != ColorSpaceType.Inherit)
             {
-                targetSurface = RequestTexture(51, context.RenderOutputSize,
-                    Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgbLinear()).DrawingSurface;
+                if (ColorSpace.Value == ColorSpaceType.Srgb && !context.ProcessingColorSpace.IsSrgb)
+                {
+                    targetSurface = RequestTexture(51, context.RenderOutputSize,
+                        Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgb()).DrawingSurface;
+                }
+                else if (ColorSpace.Value == ColorSpaceType.LinearSrgb && context.ProcessingColorSpace.IsSrgb)
+                {
+                    targetSurface = RequestTexture(51, context.RenderOutputSize,
+                        Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgbLinear()).DrawingSurface;
+                }
             }
         }
 
-        targetSurface.Canvas.DrawRect(0, 0, context.RenderOutputSize.X, context.RenderOutputSize.Y, paint);
+        targetSurface.Canvas.DrawRect(0, 0, width, height, paint);
 
         if (targetSurface != surface)
         {
+            int saved = surface.Canvas.Save();
+            if (scale)
+            {
+                surface.Canvas.Scale((float)context.ChunkResolution.Multiplier(),
+                    (float)context.ChunkResolution.Multiplier());
+            }
+
             surface.Canvas.DrawSurface(targetSurface, 0, 0);
+            surface.Canvas.RestoreToCount(saved);
         }
     }
 

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/EllipseVectorData.cs

@@ -21,7 +21,7 @@ public class EllipseVectorData : ShapeVectorData, IReadOnlyEllipseData
         RectD.FromCenterAndSize(Center, Radius * 2).Inflate(StrokeWidth / 2);
 
     public override ShapeCorners TransformationCorners =>
-        new ShapeCorners(Center, Radius * 2).WithMatrix(TransformationMatrix);
+        new ShapeCorners(VisualAABB).WithMatrix(TransformationMatrix);
 
 
     public EllipseVectorData(VecD center, VecD radius)

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/RectangleVectorData.cs

@@ -27,7 +27,7 @@ public class RectangleVectorData : ShapeVectorData, IReadOnlyRectangleData
     }
 
     public override ShapeCorners TransformationCorners =>
-        new ShapeCorners(Center, Size).WithMatrix(TransformationMatrix);
+        new ShapeCorners(VisualAABB).WithMatrix(TransformationMatrix);
 
 
     public RectangleVectorData(VecD center, VecD size)

+ 79 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/EvaluatePathNode.cs

@@ -0,0 +1,79 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("EvaluatePath")]
+public class EvaluatePathNode : Node
+{
+    public InputProperty<ShapeVectorData> Shape { get; }
+    public InputProperty<double> Offset { get; }
+    public InputProperty<bool> NormalizeOffset { get; }
+
+    public OutputProperty<VecD> Position { get; }
+    public OutputProperty<VecD> Tangent { get; }
+    public OutputProperty<Matrix3X3> Matrix { get; }
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+    protected override CacheTriggerFlags CacheTrigger => CacheTriggerFlags.All;
+
+    public EvaluatePathNode()
+    {
+        Shape = CreateInput<ShapeVectorData>("Shape", "SHAPE", null);
+        Offset = CreateInput<double>("NormalizedOffset", "OFFSET", 0.0);
+        NormalizeOffset = CreateInput<bool>("NormalizeOffset", "NORMALIZE_OFFSET", true);
+
+        Position = CreateOutput<VecD>("Position", "POSITION", VecD.Zero);
+        Tangent = CreateOutput<VecD>("Tangent", "TANGENT", VecD.Zero);
+        Matrix = CreateOutput<Matrix3X3>("Matrix", "MATRIX", Matrix3X3.Identity);
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        if(Shape.Value == null)
+        {
+            Position.Value = VecD.Zero;
+            Tangent.Value = VecD.Zero;
+            Matrix.Value = Matrix3X3.Identity;
+            return;
+        }
+
+        double offset = Offset.Value;
+
+        var path = Shape.Value.ToPath(true);
+
+        if (path == null)
+        {
+            Position.Value = VecD.Zero;
+            Tangent.Value = VecD.Zero;
+            Matrix.Value = Matrix3X3.Identity;
+            return;
+        }
+
+        float absoluteOffset = (float)offset;
+
+        if (NormalizeOffset.Value)
+        {
+            offset = Math.Clamp(offset, 0.0, 1.0);
+            double length = path.Length;
+            absoluteOffset = (float)(length * offset);
+        }
+
+        Vec4D data = path.GetPositionAndTangentAtDistance(absoluteOffset, false);
+        Matrix3X3 matrix = path.GetMatrixAtDistance(absoluteOffset, false, PathMeasureMatrixMode.GetPositionAndTangent);
+
+        Position.Value = new VecD(data.X, data.Y);
+        Tangent.Value = new VecD(data.Z, data.W);
+
+        Matrix.Value = matrix;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new EvaluatePathNode();
+    }
+}

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changes/Drawing/TransformSelected_UpdateableChange.cs

@@ -85,7 +85,7 @@ internal class TransformSelected_UpdateableChange : InterruptableUpdateableChang
 
         if (memberData.Count == 1 && firstLayer is VectorLayerNode vectorLayer)
         {
-            tightBounds = vectorLayer.EmbeddedShapeData?.GeometryAABB ?? default;
+            tightBounds = vectorLayer.EmbeddedShapeData?.VisualAABB ?? default;
         }
 
         for (var i = 1; i < memberData.Count; i++)

+ 5 - 0
src/PixiEditor.SVG/Elements/SvgPolyline.cs

@@ -47,6 +47,11 @@ public class SvgPolyline() : SvgPrimitive("polyline")
 
                     nextSpaceIsSeparator = false;
                 }
+                else
+                {
+                    x = ParseNumber(currentNumberString);
+                    currentNumberString = string.Empty;
+                }
             }
             else if (char.IsDigit(character) || character == '.' || character == '-' || character == '+')
             {

BIN
src/PixiEditor.UI.Common/Fonts/PixiPerfect.ttf


+ 4 - 0
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml

@@ -35,6 +35,7 @@
             <system:String x:Key="icon-circle">&#xE930;</system:String>
             <system:String x:Key="icon-clock">&#xE931;</system:String>
             <system:String x:Key="icon-closed-grid">&#xE913;</system:String>
+            <system:String x:Key="icon-coins">&#xE9C5;</system:String>
             <system:String x:Key="icon-color-palette">&#xE932;</system:String>
             <system:String x:Key="icon-color-picker">&#xE933;</system:String>
             <system:String x:Key="icon-color-sliders">&#xE934;</system:String>
@@ -48,6 +49,7 @@
             <system:String x:Key="icon-create-mask">&#xE93A;</system:String>
             <system:String x:Key="icon-crop">&#xE93C;</system:String>
             <system:String x:Key="icon-crop-to-selection">&#xE93B;</system:String>
+            <system:String x:Key="icon-crosshair">&#xE9C6;</system:String>
             <system:String x:Key="icon-crossroad">&#xE91D;</system:String>
             <system:String x:Key="icon-database">&#xE93D;</system:String>
             <system:String x:Key="icon-deselect">&#xE93E;</system:String>
@@ -113,6 +115,7 @@
             <system:String x:Key="icon-lowres-square">&#xE9A8;</system:String>
             <system:String x:Key="icon-magic">&#xE90D;</system:String>
             <system:String x:Key="icon-magic-wand">&#xE95D;</system:String>
+            <system:String x:Key="icon-map">&#xE9C4;</system:String>
             <system:String x:Key="icon-mask-ghost">&#xE99E;</system:String>
             <system:String x:Key="icon-merge">&#xE95F;</system:String>
             <system:String x:Key="icon-message">&#xE98F;</system:String>
@@ -182,6 +185,7 @@
             <system:String x:Key="icon-text-underline">&#xE9B8;</system:String>
             <system:String x:Key="icon-timeline">&#xE9A3;</system:String>
             <system:String x:Key="icon-tool">&#xE977;</system:String>
+            <system:String x:Key="icon-train">&#xE9C3;</system:String>
             <system:String x:Key="icon-trash">&#xE978;</system:String>
             <system:String x:Key="icon-twitter">&#xE904;</system:String>
             <system:String x:Key="icon-undo">&#xE979;</system:String>

+ 4 - 0
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml.cs

@@ -31,6 +31,7 @@ public static partial class PixiPerfectIcons
     public const string Circle = "\uE930";
     public const string Clock = "\uE931";
     public const string ClosedGrid = "\uE913";
+    public const string Coins = "\uE9C5";
     public const string ColorPalette = "\uE932";
     public const string ColorPicker = "\uE933";
     public const string ColorSliders = "\uE934";
@@ -44,6 +45,7 @@ public static partial class PixiPerfectIcons
     public const string CreateMask = "\uE93A";
     public const string Crop = "\uE93C";
     public const string CropToSelection = "\uE93B";
+    public const string Crosshair = "\uE9C6";
     public const string Crossroad = "\uE91D";
     public const string Database = "\uE93D";
     public const string Deselect = "\uE93E";
@@ -109,6 +111,7 @@ public static partial class PixiPerfectIcons
     public const string LowresSquare = "\uE9A8";
     public const string Magic = "\uE90D";
     public const string MagicWand = "\uE95D";
+    public const string Map = "\uE9C4";
     public const string MaskGhost = "\uE99E";
     public const string Merge = "\uE95F";
     public const string Message = "\uE98F";
@@ -178,6 +181,7 @@ public static partial class PixiPerfectIcons
     public const string TextUnderline = "\uE9B8";
     public const string Timeline = "\uE9A3";
     public const string Tool = "\uE977";
+    public const string Train = "\uE9C3";
     public const string Trash = "\uE978";
     public const string Twitter = "\uE904";
     public const string Undo = "\uE979";

+ 4 - 0
src/PixiEditor.UI.Common/Fonts/defs.svg

@@ -218,4 +218,8 @@
 <glyph unicode="&#xe9c0;" glyph-name="step-back" data-tags="step-back" d="M810.667 106.667c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v682.667c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-682.667zM570.667 73.344l-426.667 341.333c-10.112 8.107-16 20.352-16 33.323s5.888 25.216 16 33.323l426.667 341.333c12.8 10.24 30.379 12.245 45.141 5.12 14.805-7.083 24.192-22.059 24.192-38.443v-682.667c0-16.384-9.387-31.36-24.192-38.443-14.763-7.125-32.341-5.12-45.141 5.12zM554.667 195.456v505.088l-315.691-252.544 315.691-252.544z" />
 <glyph unicode="&#xe9c1;" glyph-name="step-forward" data-tags="step-forward" d="M213.333 789.333c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-682.667c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v682.667zM453.333 822.656l426.667-341.333c10.112-8.107 16-20.352 16-33.323s-5.888-25.216-16-33.323l-426.667-341.333c-12.8-10.24-30.379-12.245-45.141-5.12-14.805 7.083-24.192 22.059-24.192 38.443v682.667c0 16.384 9.387 31.36 24.192 38.443 14.763 7.125 32.341 5.12 45.141-5.12zM469.333 700.544v-505.088l315.691 252.544-315.691 252.544z" />
 <glyph unicode="&#xe9c2;" glyph-name="step-end" data-tags="step-end" d="M261.333 822.656l426.667-341.333c10.112-8.107 16-20.352 16-33.323s-5.888-25.216-16-33.323l-426.667-341.333c-12.8-10.24-30.379-12.245-45.141-5.12-14.805 7.083-24.192 22.059-24.192 38.443v682.667c0 16.384 9.387 31.36 24.192 38.443 14.763 7.125 32.341 5.12 45.141-5.12zM277.333 700.544v-505.088l315.691 252.544-315.691 252.544zM746.667 789.333c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-682.667c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v682.667z" />
+<glyph unicode="&#xe9c3;" glyph-name="train" data-tags="train-front" d="M298.667 827.733c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-166.4c0-70.229 57.771-128 128-128s128 57.771 128 128v166.4c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-166.4c0-117.035-96.299-213.333-213.333-213.333s-213.333 96.299-213.333 213.333v166.4zM414.165 350.165c16.64-16.64 16.64-43.691 0-60.331s-43.691-16.64-60.331 0l-42.667 42.667c-16.64 16.64-16.64 43.691 0 60.331s43.691 16.64 60.331 0l42.667-42.667zM670.165 289.835c-16.64-16.64-43.691-16.64-60.331 0s-16.64 43.691 0 60.331l42.667 42.667c16.64 16.64 43.691 16.64 60.331 0s16.64-43.691 0-60.331l-42.667-42.667zM384 106.667c-143.36 0-256 112.64-256 256v170.667c0 210.645 173.355 384 384 384s384-173.355 384-384v-170.667c0-143.36-112.64-256-256-256h-256zM384 192h256c95.573 0 170.667 75.093 170.667 170.667v170.667c0 163.84-134.827 298.667-298.667 298.667s-298.667-134.827-298.667-298.667v-170.667c0-95.573 75.093-170.667 170.667-170.667zM305.835 173.013c13.056 19.584 39.552 24.875 59.179 11.819 19.584-13.056 24.875-39.552 11.819-59.179l-85.333-128c-13.056-19.584-39.552-24.875-59.179-11.819-19.584 13.056-24.875 39.552-11.819 59.179l85.333 128zM647.168 125.653c-13.056 19.627-7.765 46.123 11.819 59.179 19.627 13.056 46.123 7.765 59.179-11.819l85.333-128c13.056-19.627 7.765-46.123-11.819-59.179-19.627-13.056-46.123-7.765-59.179 11.819l-85.333 128z" />
+<glyph unicode="&#xe9c4;" glyph-name="map" data-tags="map" d="M620.928 761.259c11.989-6.016 26.155-6.016 38.144 0l156.117 78.080 18.56 6.699 19.584 2.261c46.805 0 85.333-38.528 85.333-85.333v-544.683l-3.328-23.509c-2.176-7.552-5.333-14.72-9.429-21.291-4.096-6.613-9.088-12.672-14.848-17.963l-19.584-13.483-194.261-97.195c-36.011-17.963-78.421-17.963-114.432 0l-179.712 89.856c-11.989 6.016-26.155 6.016-38.144 0l-156.117-78.080c-11.861-5.888-24.917-8.96-38.144-8.96-46.805 0-85.333 38.485-85.333 85.333v544.683c0 32.256 18.347 61.867 47.189 76.245l194.261 97.195c36.011 17.963 78.421 17.963 114.432 0l179.712-89.856zM170.667 132.949l156.117 78.080c35.968 18.005 78.464 18.005 114.432 0l179.712-89.813c11.989-6.016 26.155-6.016 38.144 0l194.261 97.152c-14.421-7.211 0 16.128 0 0v543.445c-1.493-0.299-2.816-0.256 0 1.152l-156.117-78.037c-35.968-18.005-78.464-18.005-114.432 0l-179.712 89.813c-11.989 6.016-26.155 6.016-38.144 0l-194.261-97.109v-544.683zM597.333 714.069c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-640c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v640zM341.333 821.931c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-640c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v640z" />
+<glyph unicode="&#xe9c5;" glyph-name="coins" data-tags="coins" d="M341.333 917.333c164.821 0 298.667-133.845 298.667-298.667s-133.845-298.667-298.667-298.667c-164.821 0-298.667 133.845-298.667 298.667s133.845 298.667 298.667 298.667zM341.333 832c-117.76 0-213.333-95.573-213.333-213.333s95.573-213.333 213.333-213.333c117.76 0 213.333 95.573 213.333 213.333s-95.573 213.333-213.333 213.333zM756.949 477.568c-22.059 8.235-33.323 32.811-25.088 54.869s32.811 33.323 54.869 25.088c116.608-43.477 194.347-155.435 194.347-279.851 0-163.84-134.827-298.667-298.667-298.667-126.123 0-239.232 79.872-281.429 198.699-7.893 22.187 3.712 46.635 25.899 54.485 22.187 7.893 46.635-3.712 54.485-25.899 30.165-84.907 110.976-141.952 201.045-141.952 117.035 0 213.333 96.299 213.333 213.333 0 88.875-55.552 168.832-138.795 199.893zM298.667 661.333c-23.552 0-42.667 19.115-42.667 42.667s19.115 42.667 42.667 42.667h42.667c23.552 0 42.667-19.115 42.667-42.667v-170.667c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v128zM682.581 337.835c-16.555 16.768-16.341 43.819 0.427 60.331 16.768 16.555 43.819 16.341 60.331-0.427l29.867-30.293c16.469-16.683 16.384-43.563-0.213-60.117l-120.32-120.32c-16.64-16.64-43.691-16.64-60.331 0s-16.64 43.691 0 60.331l90.368 90.368-0.128 0.128z" />
+<glyph unicode="&#xe9c6;" glyph-name="crosshair" data-tags="crosshair" d="M512 917.333c259.029 0 469.333-210.304 469.333-469.333s-210.304-469.333-469.333-469.333c-259.029 0-469.333 210.304-469.333 469.333s210.304 469.333 469.333 469.333zM512 832c-211.925 0-384-172.075-384-384s172.075-384 384-384c211.925 0 384 172.075 384 384s-172.075 384-384 384zM938.667 490.667c23.552 0 42.667-19.115 42.667-42.667s-19.115-42.667-42.667-42.667h-170.667c-23.552 0-42.667 19.115-42.667 42.667s19.115 42.667 42.667 42.667h170.667zM256 490.667c23.552 0 42.667-19.115 42.667-42.667s-19.115-42.667-42.667-42.667h-170.667c-23.552 0-42.667 19.115-42.667 42.667s19.115 42.667 42.667 42.667h170.667zM554.667 704c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v170.667c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-170.667zM554.667 21.333c0-23.552-19.115-42.667-42.667-42.667s-42.667 19.115-42.667 42.667v170.667c0 23.552 19.115 42.667 42.667 42.667s42.667-19.115 42.667-42.667v-170.667z" />
 </font></defs></svg>

+ 14 - 5
src/PixiEditor/Data/Localization/Languages/en.json

@@ -557,10 +557,10 @@
   "LOCALIZATION_DEBUG_WINDOW_TITLE": "Localization Debug Window",
   "COMMAND_DEBUG_WINDOW_TITLE": "Command Debug Window",
   "SHORTCUTS_TITLE": "Shortcuts",
-  "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_PERSPECTIVE": "Drag handles to scale transform. Hold Ctrl and drag a handle to move the handle freely. Hold Shift to scale proportionally. Hold Alt and drag a side handle to shear. Drag outside handles to rotate.",
-  "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_NOPERSPECTIVE": "Drag handles to scale transform. Hold Shift to scale proportionally. Hold Alt and drag a side handle to shear. Drag outside handles to rotate.",
-  "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_NOSHEAR_NOPERSPECTIVE": "Drag handles to scale transform. Hold Shift to scale proportionally. Drag outside handles to rotate.",
-  "TRANSFORM_ACTION_DISPLAY_SCALE_NOROTATE_NOSHEAR_NOPERSPECTIVE": "Drag handles to scale transform. Hold Shift to scale proportionally.",
+  "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_PERSPECTIVE": "Drag handles to scale transform. Hold Ctrl and drag a handle to scale from center. Hold Shift to scale proportionally. Hold Alt and drag a side handle to shear. Drag outside handles to rotate.",
+  "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_NOPERSPECTIVE": "Drag handles to scale transform. Hold Ctrl and drag a handle to scale from center. Hold Shift to scale proportionally. Hold Alt and drag a side handle to shear. Drag outside handles to rotate.",
+  "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_NOSHEAR_NOPERSPECTIVE": "Drag handles to scale transform. Hold Ctrl and drag a handle to scale from center. Hold Shift to scale proportionally. Drag outside handles to rotate.",
+  "TRANSFORM_ACTION_DISPLAY_SCALE_NOROTATE_NOSHEAR_NOPERSPECTIVE": "Drag handles to scale transform. Hold Ctrl and drag a handle to scale from center. Hold Shift to scale proportionally.",
   "LOCAL_PALETTE_SOURCE_NAME": "Local",
   "ERROR_FORBIDDEN_UNIQUE_NAME": "Extension unique name cannot start with 'pixieditor'.",
   "ERROR_MISSING_METADATA": "Extension metadata key '{0}' is missing.",
@@ -1066,5 +1066,14 @@
   "MEDIUM_QUALITY_PRESET": "Medium",
   "HIGH_QUALITY_PRESET": "High",
   "VERY_HIGH_QUALITY_PRESET": "Very High",
-  "EXPORT_FRAMES": "Export Frames"
+  "EXPORT_FRAMES": "Export Frames",
+  "NORMALIZE_OFFSET": "Normalize Offset",
+  "TANGENT": "Tangent",
+  "EVALUATE_PATH_NODE": "Evaluate Path",
+  "OLD_MIN": "Old Min",
+  "OLD_MAX": "Old Max",
+  "NEW_MIN": "New Min",
+  "NEW_MAX": "New Max",
+  "REMAP_NODE": "Remap",
+  "TEXT_TOOL_ACTION_DISPLAY": "Click on the canvas to add a new text (drag while clicking to set the size). Click on existing text to edit it."
 }

+ 176 - 0
src/PixiEditor/Helpers/Extensions/KeyExtensions.cs

@@ -0,0 +1,176 @@
+using Avalonia.Input;
+
+namespace PixiEditor.Helpers.Extensions;
+
+public static class KeyExtensions
+{
+    public static PhysicalKey ToPhysicalKey(this Key key)
+        => key switch
+        {
+            Key.None => PhysicalKey.None,
+
+            // Writing System Keys
+            Key.Oem3 => PhysicalKey.Backquote,
+            Key.Oem5 => PhysicalKey.Backslash, // IntlYen also maps to Oem5
+            Key.Oem4 => PhysicalKey.BracketLeft,
+            Key.Oem6 => PhysicalKey.BracketRight,
+            Key.OemComma => PhysicalKey.Comma,
+            Key.D0 => PhysicalKey.Digit0,
+            Key.D1 => PhysicalKey.Digit1,
+            Key.D2 => PhysicalKey.Digit2,
+            Key.D3 => PhysicalKey.Digit3,
+            Key.D4 => PhysicalKey.Digit4,
+            Key.D5 => PhysicalKey.Digit5,
+            Key.D6 => PhysicalKey.Digit6,
+            Key.D7 => PhysicalKey.Digit7,
+            Key.D8 => PhysicalKey.Digit8,
+            Key.D9 => PhysicalKey.Digit9,
+            Key.OemPlus => PhysicalKey.Equal, // NumPadEqual also maps here
+            Key.Oem102 => PhysicalKey.IntlBackslash, // IntlRo also maps here
+            Key.A => PhysicalKey.A,
+            Key.B => PhysicalKey.B,
+            Key.C => PhysicalKey.C,
+            Key.D => PhysicalKey.D,
+            Key.E => PhysicalKey.E,
+            Key.F => PhysicalKey.F,
+            Key.G => PhysicalKey.G,
+            Key.H => PhysicalKey.H,
+            Key.I => PhysicalKey.I,
+            Key.J => PhysicalKey.J,
+            Key.K => PhysicalKey.K,
+            Key.L => PhysicalKey.L,
+            Key.M => PhysicalKey.M,
+            Key.N => PhysicalKey.N,
+            Key.O => PhysicalKey.O,
+            Key.P => PhysicalKey.P,
+            Key.Q => PhysicalKey.Q,
+            Key.R => PhysicalKey.R,
+            Key.S => PhysicalKey.S,
+            Key.T => PhysicalKey.T,
+            Key.U => PhysicalKey.U,
+            Key.V => PhysicalKey.V,
+            Key.W => PhysicalKey.W,
+            Key.X => PhysicalKey.X,
+            Key.Y => PhysicalKey.Y,
+            Key.Z => PhysicalKey.Z,
+            Key.OemMinus => PhysicalKey.Minus,
+            Key.OemPeriod => PhysicalKey.Period,
+            Key.Oem7 => PhysicalKey.Quote,
+            Key.Oem1 => PhysicalKey.Semicolon,
+            Key.Oem2 => PhysicalKey.Slash,
+
+            // Functional Keys
+            Key.LeftAlt => PhysicalKey.AltLeft,
+            Key.RightAlt => PhysicalKey.AltRight,
+            Key.Back => PhysicalKey.Backspace,
+            Key.CapsLock => PhysicalKey.CapsLock,
+            Key.Apps => PhysicalKey.ContextMenu,
+            Key.LeftCtrl => PhysicalKey.ControlLeft,
+            Key.RightCtrl => PhysicalKey.ControlRight,
+            Key.Enter => PhysicalKey.Enter, // NumPadEnter also maps here
+            Key.LWin => PhysicalKey.MetaLeft,
+            Key.RWin => PhysicalKey.MetaRight,
+            Key.LeftShift => PhysicalKey.ShiftLeft,
+            Key.RightShift => PhysicalKey.ShiftRight,
+            Key.Space => PhysicalKey.Space,
+            Key.Tab => PhysicalKey.Tab,
+            Key.ImeConvert => PhysicalKey.Convert,
+            Key.HanjaMode => PhysicalKey.Lang2,
+            Key.DbeKatakana => PhysicalKey.Lang3,
+            Key.OemAuto => PhysicalKey.Lang5,
+            Key.ImeNonConvert => PhysicalKey.NonConvert,
+
+            // Control Pad Section
+            Key.Delete => PhysicalKey.Delete,
+            Key.End => PhysicalKey.End,
+            Key.Help => PhysicalKey.Help,
+            Key.Home => PhysicalKey.Home,
+            Key.Insert => PhysicalKey.Insert,
+            Key.PageDown => PhysicalKey.PageDown,
+            Key.PageUp => PhysicalKey.PageUp,
+
+            // Arrow Pad Section
+            Key.Down => PhysicalKey.ArrowDown,
+            Key.Left => PhysicalKey.ArrowLeft,
+            Key.Right => PhysicalKey.ArrowRight,
+            Key.Up => PhysicalKey.ArrowUp,
+
+            // Numpad Section
+            Key.NumLock => PhysicalKey.NumLock,
+            Key.NumPad0 => PhysicalKey.NumPad0,
+            Key.NumPad1 => PhysicalKey.NumPad1,
+            Key.NumPad2 => PhysicalKey.NumPad2,
+            Key.NumPad3 => PhysicalKey.NumPad3,
+            Key.NumPad4 => PhysicalKey.NumPad4,
+            Key.NumPad5 => PhysicalKey.NumPad5,
+            Key.NumPad6 => PhysicalKey.NumPad6,
+            Key.NumPad7 => PhysicalKey.NumPad7,
+            Key.NumPad8 => PhysicalKey.NumPad8,
+            Key.NumPad9 => PhysicalKey.NumPad9,
+            Key.Add => PhysicalKey.NumPadAdd,
+            Key.Clear => PhysicalKey.NumPadClear,
+            Key.AbntC2 => PhysicalKey.NumPadComma,
+            Key.Decimal => PhysicalKey.NumPadDecimal,
+            Key.Divide => PhysicalKey.NumPadDivide,
+            Key.Multiply => PhysicalKey.NumPadMultiply,
+            Key.Subtract => PhysicalKey.NumPadSubtract,
+
+            // These are overloaded with standard keys
+            // Use primary mappings only
+            Key.Escape => PhysicalKey.Escape,
+            Key.F1 => PhysicalKey.F1,
+            Key.F2 => PhysicalKey.F2,
+            Key.F3 => PhysicalKey.F3,
+            Key.F4 => PhysicalKey.F4,
+            Key.F5 => PhysicalKey.F5,
+            Key.F6 => PhysicalKey.F6,
+            Key.F7 => PhysicalKey.F7,
+            Key.F8 => PhysicalKey.F8,
+            Key.F9 => PhysicalKey.F9,
+            Key.F10 => PhysicalKey.F10,
+            Key.F11 => PhysicalKey.F11,
+            Key.F12 => PhysicalKey.F12,
+            Key.F13 => PhysicalKey.F13,
+            Key.F14 => PhysicalKey.F14,
+            Key.F15 => PhysicalKey.F15,
+            Key.F16 => PhysicalKey.F16,
+            Key.F17 => PhysicalKey.F17,
+            Key.F18 => PhysicalKey.F18,
+            Key.F19 => PhysicalKey.F19,
+            Key.F20 => PhysicalKey.F20,
+            Key.F21 => PhysicalKey.F21,
+            Key.F22 => PhysicalKey.F22,
+            Key.F23 => PhysicalKey.F23,
+            Key.F24 => PhysicalKey.F24,
+            Key.PrintScreen => PhysicalKey.PrintScreen,
+            Key.Scroll => PhysicalKey.ScrollLock,
+            Key.Pause => PhysicalKey.Pause,
+
+            // Media Keys
+            Key.BrowserBack => PhysicalKey.BrowserBack,
+            Key.BrowserFavorites => PhysicalKey.BrowserFavorites,
+            Key.BrowserForward => PhysicalKey.BrowserForward,
+            Key.BrowserHome => PhysicalKey.BrowserHome,
+            Key.BrowserRefresh => PhysicalKey.BrowserRefresh,
+            Key.BrowserSearch => PhysicalKey.BrowserSearch,
+            Key.BrowserStop => PhysicalKey.BrowserStop,
+            Key.LaunchApplication1 => PhysicalKey.LaunchApp1,
+            Key.LaunchApplication2 => PhysicalKey.LaunchApp2,
+            Key.LaunchMail => PhysicalKey.LaunchMail,
+            Key.MediaPlayPause => PhysicalKey.MediaPlayPause,
+            Key.SelectMedia => PhysicalKey.MediaSelect,
+            Key.MediaStop => PhysicalKey.MediaStop,
+            Key.MediaNextTrack => PhysicalKey.MediaTrackNext,
+            Key.MediaPreviousTrack => PhysicalKey.MediaTrackPrevious,
+            Key.Sleep => PhysicalKey.Sleep,
+            Key.VolumeDown => PhysicalKey.AudioVolumeDown,
+            Key.VolumeMute => PhysicalKey.AudioVolumeMute,
+            Key.VolumeUp => PhysicalKey.AudioVolumeUp,
+
+            // Legacy
+            Key.OemCopy => PhysicalKey.Copy,
+            Key.Select => PhysicalKey.Select,
+
+            _ => PhysicalKey.None
+        };
+}

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

@@ -41,7 +41,7 @@ internal class Command : MarkupExtension
 
         if (commandController is null)
         {
-            commandController = CommandController.Current; // TODO: Find a better way to get the current CommandController
+            commandController = CommandController.Current;
         }
 
         bool contains = commandController.Commands.ContainsKey(Name);

+ 61 - 8
src/PixiEditor/Models/Commands/XAML/NativeMenu.cs

@@ -1,13 +1,20 @@
 using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Input;
 using Avalonia.Layout;
 using Avalonia.Media;
 using Avalonia.Media.Imaging;
 using Avalonia.Platform;
+using CommunityToolkit.Mvvm.Input;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Models.Commands.CommandContext;
+using PixiEditor.Models.Commands.Evaluators;
+using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Input;
+using PixiEditor.ViewModels;
+using PixiEditor.Views;
 
 namespace PixiEditor.Models.Commands.XAML;
 
@@ -15,20 +22,23 @@ internal class NativeMenu : global::Avalonia.Controls.Menu
 {
     public const double IconDimensions = 18;
     public const double IconFontSize = 18;
-    
+
     public static readonly AttachedProperty<string> CommandProperty =
         AvaloniaProperty.RegisterAttached<NativeMenu, NativeMenuItem, string>(nameof(Command));
 
     public static readonly AttachedProperty<string> LocalizationKeyHeaderProperty =
         AvaloniaProperty.RegisterAttached<NativeMenu, NativeMenuItem, string>("LocalizationKeyHeader");
 
-    public static void SetLocalizationKeyHeader(NativeMenuItem obj, string value) => obj.SetValue(LocalizationKeyHeaderProperty, value);
+    public static void SetLocalizationKeyHeader(NativeMenuItem obj, string value) =>
+        obj.SetValue(LocalizationKeyHeaderProperty, value);
+
     public static string GetLocalizationKeyHeader(NativeMenuItem obj) => obj.GetValue(LocalizationKeyHeaderProperty);
 
     static NativeMenu()
     {
         CommandProperty.Changed.Subscribe(CommandChanged);
     }
+
     public static string GetCommand(NativeMenuItem menu) => (string)menu.GetValue(CommandProperty);
     public static void SetCommand(NativeMenuItem menu, string value) => menu.SetValue(CommandProperty, value);
 
@@ -47,33 +57,76 @@ internal class NativeMenu : global::Avalonia.Controls.Menu
 
         var command = CommandController.Current.Commands[value];
 
-        bool canExecute = command.CanExecute();
-
         Bitmap? bitmapIcon = command.GetIcon().ToBitmap(new PixelSize((int)IconDimensions, (int)IconDimensions));
 
-        item.Command = Command.GetICommand(command, new MenuSourceInfo(MenuType.Menu), false);
+        var iCommand = Command.GetICommand(command, new MenuSourceInfo(MenuType.Menu), false);
+
+        RelayCommand<object> wrapper = new RelayCommand<object>(parameter =>
+        {
+            if (!ShortcutController.ShortcutExecutionBlocked)
+            {
+                if (iCommand.CanExecute(parameter))
+                {
+                    iCommand.Execute(parameter);
+                }
+            }
+            else
+            {
+                var focusedElement = MainWindow.Current.FocusManager.GetFocusedElement();
+                focusedElement?.RaiseEvent(new KeyEventArgs()
+                {
+                    Key = command.Shortcut.Key,
+                    KeyModifiers = command.Shortcut.Modifiers,
+                    KeyDeviceType = KeyDeviceType.Keyboard,
+                    PhysicalKey = command.Shortcut.Key.ToPhysicalKey(),
+                    Source = item,
+                    RoutedEvent = InputElement.KeyDownEvent,
+                    KeySymbol = command.Shortcut.Key.ToString()
+                });
+            }
+        });
+
+        item.Command = wrapper;
         item.Icon = bitmapIcon;
+        // Setting gestures causes issues, since macos is not aware of active contexts and might bypass Command Controller blockers
+        // Making a wrapper prevents this, but also doesn't pass key down events to the app. So that's why we invoke the event manually
+
         item.Bind(NativeMenuItem.GestureProperty, ShortcutBinding.GetBinding(command, null, true));
     }
 
+    private static RawInputModifiers ToRawModifiers(KeyModifiers modifiers)
+    {
+        RawInputModifiers raw = 0;
+        if (modifiers.HasFlag(KeyModifiers.Shift))
+            raw |= RawInputModifiers.Shift;
+        if (modifiers.HasFlag(KeyModifiers.Control))
+            raw |= RawInputModifiers.Control;
+        if (modifiers.HasFlag(KeyModifiers.Alt))
+            raw |= RawInputModifiers.Alt;
+        if (modifiers.HasFlag(KeyModifiers.Meta))
+            raw |= RawInputModifiers.Meta;
+
+        return raw;
+    }
+
     private static void HandleDesignMode(NativeMenuItem item, string name)
     {
         var command = DesignCommandHelpers.GetCommandAttribute(name);
         item.Gesture = new KeyCombination(command.Key, command.Modifiers).ToKeyGesture();
     }
-    
+
     private static Bitmap ImageToBitmap(IImage? image, int width, int height)
     {
         if (image is null)
         {
             return null;
         }
-        
+
         RenderTargetBitmap renderTargetBitmap = new RenderTargetBitmap(new PixelSize(width, height));
         var ctx = renderTargetBitmap.CreateDrawingContext();
         image.Draw(ctx, new Rect(0, 0, width, height), new Rect(0, 0, width, height));
         ctx.Dispose();
-        
+
         return renderTargetBitmap;
     }
 }

+ 3 - 0
src/PixiEditor/Models/Controllers/ClipboardController.cs

@@ -31,6 +31,7 @@ using PixiEditor.Models.Handlers;
 using PixiEditor.Parser;
 using PixiEditor.UI.Common.Localization;
 using PixiEditor.ViewModels.Document;
+using PixiEditor.ViewModels.Tools.Tools;
 using Bitmap = Avalonia.Media.Imaging.Bitmap;
 
 namespace PixiEditor.Models.Controllers;
@@ -241,11 +242,13 @@ internal static class ClipboardController
                     return false;
                 }
 
+                manager.Owner.ToolsSubViewModel.SetActiveTool<MoveToolViewModel>(false);
                 document.Operations.SetSelectedMember(guid.Value);
                 document.Operations.PasteImageWithTransform(dataImage.Image, position, guid.Value, false);
             }
             else
             {
+                manager.Owner.ToolsSubViewModel.SetActiveTool<MoveToolViewModel>(false);
                 document.Operations.PasteImageWithTransform(dataImage.Image, position);
             }
 

+ 8 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorPathToolExecutor.cs

@@ -109,6 +109,12 @@ internal class VectorPathToolExecutor : UpdateableChangeExecutor, IPathExecutorF
             return ExecutionState.Error;
         }
 
+        document.Operations.InvokeCustomAction(
+            () =>
+        {
+            restoreSnapping = SimpleShapeToolExecutor.DisableSelfSnapping(member.Id, document);
+        }, false);
+
         restoreSnapping = SimpleShapeToolExecutor.DisableSelfSnapping(member.Id, document);
         return ExecutionState.Success;
     }
@@ -137,7 +143,8 @@ internal class VectorPathToolExecutor : UpdateableChangeExecutor, IPathExecutorF
 
     public override void OnLeftMouseButtonDown(MouseOnCanvasEventArgs args)
     {
-        if (args.KeyModifiers.HasFlag(KeyModifiers.Shift) || NeedsNewLayer(member, document.AnimationHandler.ActiveFrameTime))
+        if (args.KeyModifiers.HasFlag(KeyModifiers.Shift) ||
+            NeedsNewLayer(member, document.AnimationHandler.ActiveFrameTime))
         {
             Guid? created =
                 document.Operations.CreateStructureMember(typeof(VectorLayerNode), ActionSource.Automated);

+ 29 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorTextToolExecutor.cs

@@ -34,6 +34,9 @@ internal class VectorTextToolExecutor : UpdateableChangeExecutor, ITextOverlayEv
     private bool isListeningForValidLayer;
     private VectorPath? onPath;
 
+    private VecD clickPos;
+    private bool wasDrawingSize;
+
     private List<Font> fontsToDispose = new();
 
     public override bool BlocksOtherActions => false;
@@ -98,6 +101,7 @@ internal class VectorTextToolExecutor : UpdateableChangeExecutor, ITextOverlayEv
                 Matrix3X3.Identity, toolbar.Spacing);
             lastText = "";
             position = controller.LastPrecisePosition;
+            clickPos = controller.LastPrecisePosition;
             // TODO: Implement proper putting on path editing
             /*if (controller.LeftMousePressed)
             {
@@ -116,6 +120,7 @@ internal class VectorTextToolExecutor : UpdateableChangeExecutor, ITextOverlayEv
     {
         var topMostWithinClick = QueryLayers<IVectorLayerHandler>(args.PositionOnCanvas);
 
+        clickPos = args.PositionOnCanvas;
         var firstLayer = topMostWithinClick.FirstOrDefault();
         args.Handled = firstLayer != null;
         if (firstLayer is not IVectorLayerHandler layerHandler)
@@ -143,6 +148,30 @@ internal class VectorTextToolExecutor : UpdateableChangeExecutor, ITextOverlayEv
             }, false);
     }
 
+    public override void OnPrecisePositionChange(VecD pos)
+    {
+        if (document.TextOverlayHandler.IsActive && internals.ChangeController.LeftMousePressed && string.IsNullOrEmpty(lastText))
+        {
+            double distance = Math.Abs(clickPos.Y - pos.Y);
+            if (!wasDrawingSize && distance < 10) return;
+            wasDrawingSize = true;
+            position = new VecD(position.X, pos.Y);
+            document.TextOverlayHandler.Position = position;
+            document.TextOverlayHandler.PreviewSize = true;
+            var textData = ConstructTextData(lastText);
+            toolbar.FontSize = distance * RichText.PtToPx;
+            internals.ActionAccumulator.AddActions(new SetShapeGeometry_Action(selectedMember.Id, textData, VectorShapeChangeType.GeometryData));
+        }
+    }
+
+    public override void OnLeftMouseButtonUp(VecD pos)
+    {
+        if (wasDrawingSize)
+        {
+            document.TextOverlayHandler.PreviewSize = false;
+        }
+    }
+
     public void OnQuickToolSwitch()
     {
         document.TextOverlayHandler.SetCursorPosition(internals.ChangeController.LastPrecisePosition);

+ 2 - 2
src/PixiEditor/Models/Files/VideoFileType.cs

@@ -21,7 +21,7 @@ internal abstract class VideoFileType : IoFileType
         job?.Report(0, new LocalizedString("WARMING_UP"));
 
         int frameRendered = 0;
-        int totalFrames = document.AnimationDataViewModel.GetVisibleFramesCount();
+        int totalFrames = document.AnimationDataViewModel.GetLastVisibleFrame() - 1;
 
         document.RenderFrames(frames, surface =>
         {
@@ -65,7 +65,7 @@ internal abstract class VideoFileType : IoFileType
         job?.Report(0, new LocalizedString("WARMING_UP"));
 
         int frameRendered = 0;
-        int totalFrames = document.AnimationDataViewModel.FramesCount;
+        int totalFrames = document.AnimationDataViewModel.GetLastVisibleFrame() - 1;
 
         document.RenderFrames(frames, surface =>
         {

+ 1 - 1
src/PixiEditor/Models/Handlers/IAnimationHandler.cs

@@ -23,7 +23,7 @@ internal interface IAnimationHandler : IDisposable
     public void RemoveSelectedKeyFrame(Guid keyFrameId);
     public void ClearSelectedKeyFrames();
     public void SetOnionSkinning(bool enabled);
-    public int FirstFrame { get; }
+    public int FirstVisibleFrame { get; }
     public int LastFrame { get; }
     public void SetOnionFrames(int frames, double opacity);
     public void SetPlayingState(bool play);

+ 1 - 0
src/PixiEditor/Models/Handlers/ITextOverlayHandler.cs

@@ -12,5 +12,6 @@ public interface ITextOverlayHandler : IHandler
     public VecD Position { get; set; }
     public double? Spacing { get; set; }
     public bool IsActive { get; }
+    public bool PreviewSize { get; set; }
     public void SetCursorPosition(VecD closestToPosition);
 }

+ 3 - 3
src/PixiEditor/Models/IO/Exporter.cs

@@ -188,7 +188,7 @@ internal class Exporter
             Directory.CreateDirectory(directory);
         }
 
-        int totalFrames = document.AnimationDataViewModel.GetVisibleFramesCount();
+        int totalFrames = document.AnimationDataViewModel.GetLastVisibleFrame() - 1;
         document.RenderFramesProgressive(
             (surface, frame) =>
         {
@@ -198,11 +198,11 @@ internal class Exporter
             if (exportConfig.ExportSize != surface.Size)
             {
                 var resized = surface.ResizeNearestNeighbor(exportConfig.ExportSize);
-                SaveAsPng(Path.Combine(directory, $"{frame}.png"), resized);
+                SaveAsPng(Path.Combine(directory, $"{frame + 1}.png"), resized);
             }
             else
             {
-                SaveAsPng(Path.Combine(directory, $"{frame}.png"), surface);
+                SaveAsPng(Path.Combine(directory, $"{frame + 1}.png"), surface);
             }
 
         }, CancellationToken.None, exportConfig.ExportOutput);

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

@@ -261,7 +261,7 @@ internal class SceneRenderer : IDisposable
         for (int i = 1; i <= animationData.OnionFrames; i++)
         {
             int frame = DocumentViewModel.AnimationHandler.ActiveFrameTime.Frame - i;
-            if (frame < DocumentViewModel.AnimationHandler.FirstFrame)
+            if (frame < DocumentViewModel.AnimationHandler.FirstVisibleFrame)
             {
                 break;
             }

+ 2 - 2
src/PixiEditor/Styles/Templates/NodeGraphView.axaml

@@ -6,13 +6,13 @@
         <Setter Property="ZoomMode" Value="Move" />
         <Setter Property="Template">
             <ControlTemplate>
-                <Grid Background="Transparent">
+                <Grid Focusable="True" Background="Transparent" Name="PART_RootPanel">
                     <Rectangle Name="PART_SelectionRectangle" HorizontalAlignment="Left"
                                VerticalAlignment="Top"
                                IsVisible="False" ZIndex="100"
                                Fill="{DynamicResource SelectionFillBrush}" Opacity="1" />
                     <Grid.ContextFlyout>
-                        <Flyout>
+                        <Flyout Placement="Pointer">
                             <nodes:NodePicker
                                 AllNodeTypeInfos="{Binding AllNodeTypeInfos, RelativeSource={RelativeSource TemplatedParent}}"
                                 SearchQuery="{Binding SearchQuery, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"

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

@@ -29,7 +29,7 @@
                             BorderBrush="{DynamicResource ThemeBorderMidBrush}">
                         <DockPanel Grid.Row="0" Grid.Column="0" LastChildFill="False" Margin="5 0">
                             <controls:SizeInput Unit="FPS"
-                                             Width="80" Height="25" HorizontalAlignment="Left"
+                                             Width="90" Height="25" HorizontalAlignment="Left"
                                              Size="{Binding Fps, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
 
                             <Button Classes="pixi-icon" DockPanel.Dock="Right"

+ 6 - 6
src/PixiEditor/ViewModels/Document/AnimationDataViewModel.cs

@@ -110,15 +110,15 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         }
     }
 
-    public int FirstFrame => cachedFirstFrame ??= keyFrames.Count > 0 ? keyFrames.Min(x => x.StartFrameBindable) : 1;
+    public int FirstVisibleFrame => cachedFirstFrame ??= keyFrames.Count > 0 ? keyFrames.Min(x => x.StartFrameBindable) : 1;
 
     public int LastFrame => cachedLastFrame ??= keyFrames.Count > 0
         ? keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable)
         : DefaultEndFrame;
 
-    public int FramesCount => LastFrame - FirstFrame;
+    public int FramesCount => LastFrame - 1;
 
-    private double ActiveNormalizedTime => (double)(ActiveFrameBindable - FirstFrame) / FramesCount;
+    private double ActiveNormalizedTime => (double)(ActiveFrameBindable - 1) / (FramesCount - 1);
 
     private int DefaultEndFrame => FrameRateBindable; // 1 second
 
@@ -239,7 +239,7 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
             keyFrame.SetDuration(newDuration);
             keyFrames.NotifyCollectionChanged();
 
-            OnPropertyChanged(nameof(FirstFrame));
+            OnPropertyChanged(nameof(FirstVisibleFrame));
             OnPropertyChanged(nameof(LastFrame));
             OnPropertyChanged(nameof(FramesCount));
         }
@@ -282,7 +282,7 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         cachedFirstFrame = null;
         cachedLastFrame = null;
 
-        OnPropertyChanged(nameof(FirstFrame));
+        OnPropertyChanged(nameof(FirstVisibleFrame));
         OnPropertyChanged(nameof(LastFrame));
         OnPropertyChanged(nameof(FramesCount));
     }
@@ -312,7 +312,7 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         cachedFirstFrame = null;
         cachedLastFrame = null;
 
-        OnPropertyChanged(nameof(FirstFrame));
+        OnPropertyChanged(nameof(FirstVisibleFrame));
         OnPropertyChanged(nameof(LastFrame));
         OnPropertyChanged(nameof(FramesCount));
     }

+ 8 - 9
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -1197,12 +1197,12 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         if (token.IsCancellationRequested)
             return [];
 
-        int firstFrame = AnimationDataViewModel.GetFirstVisibleFrame();
+        int firstFrame = 1;
         int lastFrame = AnimationDataViewModel.GetLastVisibleFrame();
 
-        int framesCount = lastFrame - firstFrame;
+        int framesCount = lastFrame;
 
-        Image[] images = new Image[framesCount];
+        Image[] images = new Image[framesCount - firstFrame];
 
         // TODO: Multi-threading
         for (int i = firstFrame; i < lastFrame; i++)
@@ -1238,10 +1238,9 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     public void RenderFramesProgressive(Action<Surface, int> processFrameAction, CancellationToken token,
         string? renderOutput)
     {
-        int firstFrame = AnimationDataViewModel.GetFirstVisibleFrame();
-        int framesCount = AnimationDataViewModel.GetLastVisibleFrame();
-        int lastFrame = firstFrame + framesCount;
-
+        int firstFrame = 1;
+        int lastFrame = AnimationDataViewModel.GetLastVisibleFrame();
+        int totalFrames = lastFrame - firstFrame;
         int activeFrame = AnimationDataViewModel.ActiveFrameBindable;
 
         for (int i = firstFrame; i < lastFrame; i++)
@@ -1249,7 +1248,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
             if (token.IsCancellationRequested)
                 return;
 
-            KeyFrameTime frameTime = new KeyFrameTime(i, (double)(i - firstFrame) / framesCount);
+            KeyFrameTime frameTime = new KeyFrameTime(i, (double)(i - firstFrame) / totalFrames);
 
             var surface = TryRenderWholeImage(frameTime, renderOutput);
             if (surface.IsT0)
@@ -1265,7 +1264,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     public bool RenderFrames(List<Image> frames, Func<Surface, Surface> processFrameAction = null,
         string? renderOutput = null)
     {
-        var firstFrame = AnimationDataViewModel.GetFirstVisibleFrame();
+        var firstFrame = 1;
         var lastFrame = AnimationDataViewModel.GetLastVisibleFrame();
 
         for (int i = firstFrame; i < lastFrame; i++)

+ 9 - 0
src/PixiEditor/ViewModels/Document/Nodes/Calculations/RemapNodeViewModel.cs

@@ -0,0 +1,9 @@
+using PixiEditor.ViewModels.Nodes;
+
+namespace PixiEditor.ViewModels.Document.Nodes.Calculations;
+
+[NodeViewModel("REMAP_NODE", "NUMBERS", PixiPerfectIcons.Map)]
+internal class RemapNodeViewModel : NodeViewModel<PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Calculations.RemapNode>
+{
+
+}

+ 10 - 0
src/PixiEditor/ViewModels/Document/Nodes/Shapes/EvaluatePathNodeViewModel.cs

@@ -0,0 +1,10 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+using PixiEditor.ViewModels.Nodes;
+
+namespace PixiEditor.ViewModels.Document.Nodes.Shapes;
+
+[NodeViewModel("EVALUATE_PATH_NODE", "SHAPE", PixiPerfectIcons.Train)]
+internal class EvaluatePathNodeViewModel : NodeViewModel<EvaluatePathNode>
+{
+
+}

+ 8 - 0
src/PixiEditor/ViewModels/Document/TransformOverlays/TextOverlayViewModel.cs

@@ -13,6 +13,7 @@ internal class TextOverlayViewModel : ObservableObject, ITextOverlayHandler
     private string text;
     private VecD position;
     private Font font;
+    private bool previewSize = false;
     private ExecutionTrigger<string> requestEditTextTrigger;
     private Matrix3X3 matrix = Matrix3X3.Identity;
     private double? spacing;
@@ -76,6 +77,12 @@ internal class TextOverlayViewModel : ObservableObject, ITextOverlayHandler
         set => SetProperty(ref cursorPosition, value);
     }
 
+    public bool PreviewSize
+    {
+        get => previewSize;
+        set => SetProperty(ref previewSize, value);
+    }
+
     public int SelectionEnd
     {
         get => selectionEnd;
@@ -119,6 +126,7 @@ internal class TextOverlayViewModel : ObservableObject, ITextOverlayHandler
         Matrix = matrix;
         Spacing = spacing;
         IsActive = true;
+        PreviewSize = false;
         RequestEditTextTrigger.Execute(this, text);
     }
 

+ 53 - 7
src/PixiEditor/ViewModels/SubViewModels/ClipboardViewModel.cs

@@ -45,6 +45,14 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         Application.Current.ForDesktopMainWindow((mainWindow) =>
         {
             ClipboardController.Initialize(mainWindow.Clipboard);
+            mainWindow.GotFocus += (sender, args) =>
+            {
+                QueueHasImageInClipboard();
+                QueueCheckCanPasteImage();
+                QueueFetchTextFromClipboard();
+                QueueCheckNodesInClipboard();
+                QueueCheckCelsInClipboard();
+            };
         });
     }
 
@@ -316,7 +324,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
 
         await ClipboardController.CopyToClipboard(doc);
 
-        hasImageInClipboard = true;
+        SetHasImageInClipboard();
     }
 
     [Command.Basic("PixiEditor.Clipboard.CopyVisible", "COPY_VISIBLE", "COPY_VISIBLE_DESCRIPTIVE",
@@ -334,7 +342,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
                 ? viewportWindowViewModel.RenderOutputName
                 : null);
 
-        hasImageInClipboard = true;
+        SetHasImageInClipboard();
     }
 
     [Command.Basic("PixiEditor.Clipboard.CopyNodes", "COPY_NODES", "COPY_NODES_DESCRIPTIVE",
@@ -355,6 +363,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         await ClipboardController.CopyNodes(selectedNodes);
 
         areNodesInClipboard = true;
+        ClearHasImageInClipboard();
     }
 
     [Command.Basic("PixiEditor.Clipboard.CopyCels", "COPY_CELS",
@@ -374,6 +383,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         await ClipboardController.CopyCels(selectedCels);
 
         areCelsInClipboard = true;
+        ClearHasImageInClipboard();
     }
 
 
@@ -565,31 +575,67 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
     private void QueueHasImageInClipboard()
     {
         QueueClipboardTask("HasImageInClipboard", ClipboardController.IsImageInClipboard, hasImageInClipboard,
-            x => hasImageInClipboard = x);
+            x =>
+            {
+                hasImageInClipboard = x;
+                CommandController.CanExecuteChanged("PixiEditor.Clipboard.HasImageInClipboard");
+            });
     }
 
     private void QueueCheckCanPasteImage()
     {
         QueueClipboardTask("CheckCanPasteImage", ClipboardController.IsImageInClipboard, canPasteImage,
-            x => canPasteImage = x);
+            x =>
+            {
+                canPasteImage = x;
+                CommandController.CanExecuteChanged("PixiEditor.Clipboard.CanPaste");
+            });
     }
 
     private void QueueFetchTextFromClipboard()
     {
         QueueClipboardTask("FetchTextFromClipboard", ClipboardController.GetTextFromClipboard, lastTextInClipboard,
-            x => lastTextInClipboard = x);
+            x =>
+            {
+                lastTextInClipboard = x;
+                CommandController.CanExecuteChanged("PixiEditor.Clipboard.CanPasteColor");
+            });
     }
 
     private void QueueCheckNodesInClipboard()
     {
         QueueClipboardTask("CheckNodesInClipboard", ClipboardController.AreNodesInClipboard, areNodesInClipboard,
-            x => areNodesInClipboard = x);
+            x =>
+            {
+                areNodesInClipboard = x;
+                CommandController.CanExecuteChanged("PixiEditor.Clipboard.CanPasteNodes");
+            });
     }
 
     private void QueueCheckCelsInClipboard()
     {
         QueueClipboardTask("CheckCelsInClipboard", ClipboardController.AreCelsInClipboard, areCelsInClipboard,
-            x => areCelsInClipboard = x);
+            x =>
+            {
+                areCelsInClipboard = x;
+                CommandController.CanExecuteChanged("PixiEditor.Clipboard.CanPasteCels");
+            });
+    }
+
+    private void SetHasImageInClipboard()
+    {
+        hasImageInClipboard = true;
+        canPasteImage = true;
+        areNodesInClipboard = false;
+        areCelsInClipboard = false;
+        lastTextInClipboard = string.Empty;
+    }
+
+    private void ClearHasImageInClipboard()
+    {
+        hasImageInClipboard = false;
+        canPasteImage = false;
+        lastTextInClipboard = string.Empty;
     }
 
     private void QueueClipboardTask<T>(string key, Func<Task<T>> task, T value, Action<T> updateAction)

+ 2 - 0
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -197,6 +197,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
 
     [Command.Basic("PixiEditor.File.OpenFileFromClipboard", "OPEN_FILE_FROM_CLIPBOARD",
         "OPEN_FILE_FROM_CLIPBOARD_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.HasImageInClipboard",
+        Icon = PixiPerfectIcons.PasteAsNewLayer,
+        MenuItemPath = "FILE/OPEN_FILE_FROM_CLIPBOARD", MenuItemOrder = 2,
         AnalyticsTrack = true)]
     public void OpenFromClipboard()
     {

+ 1 - 0
src/PixiEditor/ViewModels/Tools/Tools/TextToolViewModel.cs

@@ -47,6 +47,7 @@ internal class TextToolViewModel : ToolViewModel, ITextToolHandler
         if (!restoring)
         {
             ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseTextTool();
+            ActionDisplay = new LocalizedString("TEXT_TOOL_ACTION_DISPLAY");
         }
     }
 

+ 2 - 1
src/PixiEditor/Views/Animations/KeyFrame.cs

@@ -155,7 +155,8 @@ internal class KeyFrame : TemplatedControl
 
     private int MousePosToFrame(PointerEventArgs e, bool round = true)
     {
-        double x = e.GetPosition(this.FindAncestorOfType<Border>()).X;
+        // 30 is a left visual padding on the timeline. TODO: Make it less...hardcoded
+        double x = e.GetPosition(this.FindAncestorOfType<Border>()).X - 30;
         int frame;
         if (round)
         {

+ 64 - 6
src/PixiEditor/Views/Animations/Timeline.cs

@@ -221,10 +221,12 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
         StepStartCommand = new RelayCommand(() =>
         {
             var keyFramesWithinActiveFrame = KeyFrames.Where(x => x.IsVisible
-                                                                  && x.StartFrameBindable < ActiveFrame).SelectMany(x => x.Children).ToList();
+                                                                  && x.StartFrameBindable < ActiveFrame)
+                .SelectMany(x => x.Children).ToList();
             if (keyFramesWithinActiveFrame.Count > 0)
             {
-                List<int> snapPoints = keyFramesWithinActiveFrame.Select(x => x.StartFrameBindable + x.DurationBindable - 1).ToList();
+                List<int> snapPoints = keyFramesWithinActiveFrame
+                    .Select(x => x.StartFrameBindable + x.DurationBindable - 1).ToList();
                 snapPoints.AddRange(KeyFrames.Select(x => x.StartFrameBindable));
                 snapPoints.RemoveAll(x => x >= ActiveFrame);
 
@@ -239,10 +241,12 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
         StepEndCommand = new RelayCommand(() =>
         {
             var keyFramesWithinActiveFrame = KeyFrames.Where(x => x.IsVisible
-                                                                  && x.StartFrameBindable + x.DurationBindable - 1 > ActiveFrame).SelectMany(x => x.Children).ToList();
+                                                                  && x.StartFrameBindable + x.DurationBindable - 1 >
+                                                                  ActiveFrame).SelectMany(x => x.Children).ToList();
             if (keyFramesWithinActiveFrame.Count > 0)
             {
-                List<int> snapPoints = keyFramesWithinActiveFrame.Select(x => x.StartFrameBindable + x.DurationBindable - 1).ToList();
+                List<int> snapPoints = keyFramesWithinActiveFrame
+                    .Select(x => x.StartFrameBindable + x.DurationBindable - 1).ToList();
                 snapPoints.AddRange(KeyFrames.Select(x => x.StartFrameBindable));
                 snapPoints.RemoveAll(x => x <= ActiveFrame);
 
@@ -328,7 +332,15 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
 
         _selectionRectangle = e.NameScope.Find<Rectangle>("PART_SelectionRectangle");
 
-        _timelineKeyFramesScroll.PointerWheelChanged += TimelineSliderOnPointerWheelChanged;
+        _timelineKeyFramesScroll.AddHandler(PointerWheelChangedEvent, (s, e) =>
+        {
+            if (!e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+            {
+                return;
+            }
+
+            TimelineSliderOnPointerWheelChanged(s, e);
+        }, RoutingStrategies.Tunnel);
         _timelineSlider.PointerWheelChanged += TimelineSliderOnPointerWheelChanged;
         _timelineKeyFramesScroll.ScrollChanged += TimelineKeyFramesScrollOnScrollChanged;
         _contentGrid.PointerPressed += ContentOnPointerPressed;
@@ -406,7 +418,7 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
 
     private void KeyFramesDragged(PointerEventArgs? e)
     {
-        if (clickedCel == null) return;
+        if (clickedCel == null || e.GetMouseButton(this) != MouseButton.Left) return;
 
         int frameUnderMouse = MousePosToFrame(e);
         int delta = frameUnderMouse - dragStartFrame;
@@ -424,6 +436,8 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
                 dragStartFrame += delta;
             }
         }
+
+        PropertyChanged(this, new PropertyChangedEventArgs(nameof(EndFrame)));
     }
 
     private void ClearSelectedKeyFrames(CelViewModel? keyFrame)
@@ -667,6 +681,17 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
         {
             newCollection.KeyFrameAdded += timeline.KeyFrames_KeyFrameAdded;
             newCollection.KeyFrameRemoved += timeline.KeyFrames_KeyFrameRemoved;
+
+            foreach (var item in newCollection)
+            {
+                foreach (var child in item.Children)
+                {
+                    if (child is CelViewModel cel)
+                    {
+                        cel.PropertyChanged += timeline.KeyFrameOnPropertyChanged;
+                    }
+                }
+            }
         }
 
         if (timeline.PropertyChanged != null)
@@ -679,6 +704,34 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
     private void KeyFrames_KeyFrameAdded(CelViewModel cel)
     {
         cel.PropertyChanged += KeyFrameOnPropertyChanged;
+        if (cel is CelGroupViewModel group)
+        {
+            group.Children.CollectionChanged += GroupChildren_CollectionChanged;
+        }
+
+        PropertyChanged(this, new PropertyChangedEventArgs(nameof(SelectedKeyFrames)));
+        PropertyChanged(this, new PropertyChangedEventArgs(nameof(EndFrame)));
+    }
+
+    private void GroupChildren_CollectionChanged(object? sender,
+        System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+    {
+        if (e.NewItems != null)
+        {
+            foreach (CelViewModel cel in e.NewItems)
+            {
+                cel.PropertyChanged += KeyFrameOnPropertyChanged;
+            }
+        }
+
+        if (e.OldItems != null)
+        {
+            foreach (CelViewModel cel in e.OldItems)
+            {
+                cel.PropertyChanged -= KeyFrameOnPropertyChanged;
+            }
+        }
+
         PropertyChanged(this, new PropertyChangedEventArgs(nameof(SelectedKeyFrames)));
         PropertyChanged(this, new PropertyChangedEventArgs(nameof(EndFrame)));
     }
@@ -689,6 +742,11 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
         {
             cel.Document.AnimationDataViewModel.RemoveSelectedKeyFrame(cel.Id);
             cel.PropertyChanged -= KeyFrameOnPropertyChanged;
+
+            if (cel is CelGroupViewModel group)
+            {
+                group.Children.CollectionChanged -= GroupChildren_CollectionChanged;
+            }
         }
 
         PropertyChanged(this, new PropertyChangedEventArgs(nameof(SelectedKeyFrames)));

+ 1 - 1
src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -258,7 +258,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         {
             foreach (var frame in videoPreviewFrames)
             {
-                frame.Dispose();
+                frame?.Dispose();
             }
         }
     }

+ 3 - 3
src/PixiEditor/Views/Main/ActionDisplayBar.axaml

@@ -33,10 +33,10 @@
                 </Style>
             </colorPicker:ColorDisplay.Styles>
         </colorPicker:ColorDisplay>
-        <DockPanel Grid.Column="1">
+        <DockPanel Grid.Column="1" ClipToBounds="True">
             <TextBlock
                 ui:Translator.LocalizedString="{Binding DataContext.ActiveActionDisplay, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=UserControl}}"
-                Foreground="White"
+                Foreground="{DynamicResource ThemeForegroundBrush}"
                 FontSize="15"
                 Margin="10,0,0,0"
                 VerticalAlignment="Center" />
@@ -47,7 +47,7 @@
                 VerticalAlignment="Center">
                 <TextBlock
                     Text="{Binding DataContext.DocumentManagerSubViewModel.ActiveDocument.CoordinatesString, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=UserControl}}"
-                    Foreground="White"
+                    Foreground="{DynamicResource ThemeForegroundBrush}"
                     FontSize="16" />
             </StackPanel>
         </DockPanel>

+ 6 - 0
src/PixiEditor/Views/Main/ViewportControls/ViewportOverlays.cs

@@ -521,6 +521,11 @@ internal class ViewportOverlays
             Source = Viewport, Path = "Document.TextOverlayViewModel.SelectionEnd", Mode = BindingMode.TwoWay
         };
 
+        Binding previewSizeBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.PreviewSize", Mode = BindingMode.OneWay
+        };
+
         textOverlay.Bind(Visual.IsVisibleProperty, isVisibleBinding);
         textOverlay.Bind(TextOverlay.TextProperty, textBinding);
         textOverlay.Bind(TextOverlay.PositionProperty, positionBinding);
@@ -530,5 +535,6 @@ internal class ViewportOverlays
         textOverlay.Bind(TextOverlay.SpacingProperty, spacingBinding);
         textOverlay.Bind(TextOverlay.CursorPositionProperty, cursorPositionBinding);
         textOverlay.Bind(TextOverlay.SelectionEndProperty, selectionEndBinding);
+        textOverlay.Bind(TextOverlay.PreviewSizeProperty, previewSizeBinding);
     }
 }

+ 26 - 0
src/PixiEditor/Views/Nodes/NodeGraphView.cs

@@ -10,6 +10,7 @@ using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Shapes;
 using Avalonia.Input;
+using Avalonia.Interactivity;
 using Avalonia.Media;
 using Avalonia.Threading;
 using Avalonia.VisualTree;
@@ -212,6 +213,8 @@ internal class NodeGraphView : Zoombox.Zoombox
     public static readonly StyledProperty<int> ActiveFrameProperty =
         AvaloniaProperty.Register<NodeGraphView, int>("ActiveFrame");
 
+    private Panel rootPanel;
+
     public NodeGraphView()
     {
         SelectNodeCommand = new RelayCommand<PointerPressedEventArgs>(SelectNode);
@@ -232,6 +235,8 @@ internal class NodeGraphView : Zoombox.Zoombox
         connectionItemsControl = e.NameScope.Find<ItemsControl>("PART_Connections");
         selectionRectangle = e.NameScope.Find<Rectangle>("PART_SelectionRectangle");
 
+        rootPanel = e.NameScope.Find<Panel>("PART_RootPanel");
+
         Dispatcher.UIThread.Post(() =>
         {
             nodeItemsControl.ItemsPanelRoot.Children.CollectionChanged += NodeItems_CollectionChanged;
@@ -240,6 +245,17 @@ internal class NodeGraphView : Zoombox.Zoombox
         });
     }
 
+    protected override void OnLoaded(RoutedEventArgs e)
+    {
+        base.OnLoaded(e);
+
+        Dispatcher.UIThread.Post(
+            () =>
+            {
+                rootPanel.Focus(NavigationMethod.Pointer);
+            }, DispatcherPriority.Input);
+    }
+
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
         nodeItemsControl.ItemsPanelRoot.Children.CollectionChanged -= NodeItems_CollectionChanged;
@@ -255,6 +271,16 @@ internal class NodeGraphView : Zoombox.Zoombox
         }
     }
 
+    protected override void OnKeyDown(KeyEventArgs e)
+    {
+        base.OnKeyDown(e);
+        if (e.Key == Key.Space)
+        {
+            rootPanel.ContextFlyout?.ShowAt(rootPanel);
+            e.Handled = true;
+        }
+    }
+
     private void NodeItems_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
     {
         if (e.Action == NotifyCollectionChangedAction.Add)

+ 1 - 0
src/PixiEditor/Views/Overlays/Handles/ControlPointHandle.cs

@@ -1,6 +1,7 @@
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
 using PixiEditor.Extensions.UI.Overlays;
+using PixiEditor.Helpers;
 using Canvas = Drawie.Backend.Core.Surfaces.Canvas;
 
 namespace PixiEditor.Views.Overlays.Handles;

+ 42 - 6
src/PixiEditor/Views/Overlays/PathOverlay/VectorPathOverlay.cs

@@ -148,10 +148,16 @@ public class VectorPathOverlay : Overlay
                 }
 
                 var handle = anchorHandles[anchorIndex];
-
-                if (point.Verb.ControlPoint1 != null || point.Verb.ControlPoint2 != null)
+                bool nextIsSelected = anchorIndex + 1 < anchorHandles.Count &&
+                                      anchorHandles[anchorIndex + 1].IsSelected;
+                bool previousIsSelected = anchorIndex - 1 >= 0 &&
+                                          anchorHandles[anchorIndex - 1].IsSelected;
+                bool drawControl1 = handle.IsSelected;
+                bool drawControl2 = nextIsSelected;
+
+                if ((point.Verb.ControlPoint1 != null || point.Verb.ControlPoint2 != null))
                 {
-                    DrawControlPoints(context, point, ref controlPointIndex);
+                    DrawControlPoints(context, point, drawControl1, drawControl2, ref controlPointIndex);
                 }
 
                 handle.Draw(context);
@@ -165,7 +171,7 @@ public class VectorPathOverlay : Overlay
         transformHandle.Draw(context);
     }
 
-    private void DrawControlPoints(Canvas context, ShapePoint point, ref int controlPointIndex)
+    private void DrawControlPoints(Canvas context, ShapePoint point, bool canDrawControl1, bool canDrawControl2, ref int controlPointIndex)
     {
         if (point.Verb.VerbType != PathVerb.Cubic) return;
 
@@ -175,7 +181,7 @@ public class VectorPathOverlay : Overlay
             controlPoint1.HitTestVisible = CapturedHandle == controlPoint1 ||
                                            controlPoint1.Position != controlPoint1.ConnectedTo.Position;
             controlPoint1.Position = (VecD)point.Verb.ControlPoint1;
-            if (controlPoint1.HitTestVisible)
+            if (controlPoint1.HitTestVisible && canDrawControl1)
             {
                 controlPoint1.Draw(context);
             }
@@ -190,7 +196,7 @@ public class VectorPathOverlay : Overlay
             controlPoint2.HitTestVisible = CapturedHandle == controlPoint2 ||
                                            controlPoint2.Position != controlPoint2.ConnectedTo.Position;
 
-            if (controlPoint2.HitTestVisible)
+            if (controlPoint2.HitTestVisible && canDrawControl2)
             {
                 controlPoint2.Draw(context);
             }
@@ -555,6 +561,25 @@ public class VectorPathOverlay : Overlay
             if (!converted)
             {
                 path = ConvertTouchingVerbsToCubic(anchor);
+
+                var subshapeContainingIndex = path.GetSubShapeContainingIndex(index);
+                int verbIndex = path.GetSubShapePointIndex(index, subshapeContainingIndex);
+
+                if (verbIndex >= 2)
+                {
+                    var previousPoint = subshapeContainingIndex.GetPreviousPoint(verbIndex);
+                    var previousPreviousPoint = subshapeContainingIndex.GetPreviousPoint(verbIndex - 1);
+
+                    if (previousPoint != null && previousPreviousPoint != null &&
+                        previousPoint.Verb.VerbType == PathVerb.Cubic &&
+                        previousPreviousPoint.Verb.VerbType == PathVerb.Cubic)
+                    {
+                        var symmetricalPoint = GetMirroredControlPoint(
+                            (VecF)previousPreviousPoint.Verb.ControlPoint2, previousPoint.Position);
+                        previousPoint.Verb.ControlPoint1 = (VecF)symmetricalPoint;
+                    }
+                }
+
                 Path = path.ToVectorPath();
                 AdjustHandles(path);
                 converted = true;
@@ -564,6 +589,17 @@ public class VectorPathOverlay : Overlay
             int localIndex = path.GetSubShapePointIndex(index, subShapeContainingIndex);
 
             HandleContinousCubicDrag(args.Point, subShapeContainingIndex, localIndex, true);
+            /*if (localIndex > 0)
+            {
+                var previousVerb = subShapeContainingIndex.Points[localIndex - 1];
+                if (previousVerb?.Verb?.ControlPoint2 != null)
+                {
+                    var thisVerb = subShapeContainingIndex.Points[localIndex].Verb;
+                    thisVerb.ControlPoint1 = (VecF)GetMirroredControlPoint(
+                        (VecF)previousVerb.Verb.ControlPoint2, thisVerb.From);
+                }
+            }*/
+
             Path = editableVectorPath.ToVectorPath();
         }
 

+ 28 - 1
src/PixiEditor/Views/Overlays/TextOverlay/TextOverlay.cs

@@ -103,6 +103,15 @@ internal class TextOverlay : Overlay
         set => SetValue(SpacingProperty, value);
     }
 
+    public static readonly StyledProperty<bool> PreviewSizeProperty = AvaloniaProperty.Register<TextOverlay, bool>(
+        nameof(PreviewSize));
+
+    public bool PreviewSize
+    {
+        get => GetValue(PreviewSizeProperty);
+        set => SetValue(PreviewSizeProperty, value);
+    }
+
     private Dictionary<KeyCombination, Action> shortcuts;
 
     private Caret caret = new Caret();
@@ -116,6 +125,7 @@ internal class TextOverlay : Overlay
 
     private Paint selectionPaint;
     private Paint opacityPaint;
+    private Paint sampleTextPaint;
 
     private int lastXMovementCursorIndex;
 
@@ -199,6 +209,10 @@ internal class TextOverlay : Overlay
         };
 
         opacityPaint = new Paint() { Color = Colors.White.WithAlpha(ThemeResources.SelectionFillColor.A) };
+        sampleTextPaint = new Paint()
+        {
+            Color = Colors.Black, Style = PaintStyle.Fill, IsAntiAliased = true
+        };
     }
 
 
@@ -210,7 +224,15 @@ internal class TextOverlay : Overlay
 
         context.SetMatrix(context.TotalMatrix.Concat(Matrix));
 
-        RenderCaret(context);
+        if (!PreviewSize)
+        {
+            RenderCaret(context);
+        }
+        else
+        {
+            RenderSampleText(context);
+        }
+
         RenderSelection(context);
 
         context.RestoreToCount(saved);
@@ -242,6 +264,11 @@ internal class TextOverlay : Overlay
         caret.Render(context);
     }
 
+    private void RenderSampleText(Canvas context)
+    {
+        context.DrawText("A", new VecD(Position.X, Position.Y), Font, sampleTextPaint);
+    }
+
     private void RenderSelection(Canvas context)
     {
         if (CursorPosition == SelectionEnd) return;

+ 6 - 6
src/PixiEditor/Views/Overlays/TransformOverlay/TransformOverlay.cs

@@ -702,14 +702,14 @@ internal class TransformOverlay : Overlay
         const double offsetInPixels = 30;
         double offsetToScale = offsetInPixels / ZoomScale;
         ShapeCorners scaled = Corners.AsRotated(-Corners.RectRotation, Corners.RectCenter);
+        var aabb = scaled.AABBBounds;
         ShapeCorners scaledCorners = new ShapeCorners()
         {
-            BottomLeft = scaled.BottomLeft - new VecD(offsetToScale, -offsetToScale),
-            BottomRight = scaled.BottomRight + new VecD(offsetToScale, offsetToScale),
-            TopLeft = scaled.TopLeft - new VecD(offsetToScale, offsetToScale),
-            TopRight = scaled.TopRight - new VecD(-offsetToScale, offsetToScale),
+            BottomLeft = aabb.BottomLeft - new VecD(offsetToScale, -offsetToScale),
+            BottomRight = aabb.BottomRight + new VecD(offsetToScale, offsetToScale),
+            TopLeft = aabb.TopLeft - new VecD(offsetToScale, offsetToScale),
+            TopRight = aabb.TopRight - new VecD(-offsetToScale, offsetToScale),
         };
-
         scaledCorners = scaledCorners.AsRotated(Corners.RectRotation, Corners.RectCenter);
 
         return base.TestHit(point) || scaledCorners.IsPointInside(point);
@@ -910,7 +910,7 @@ internal class TransformOverlay : Overlay
             {
                 bool shouldAlign =
                     (CornerFreedom is TransformCornerFreedom.ScaleProportionally or TransformCornerFreedom.Scale) &&
-                    Corners.IsAlignedToPixels;
+                    Corners.IsAlignedToPixels && CanAlignToPixels;
 
                 newCorners = shouldAlign
                     ? TransformHelper.AlignToPixels((ShapeCorners)newCorners)