Browse Source

Merge branch 'master' into linux
sync

flabbet 5 tháng trước cách đây
mục cha
commit
736e68bccb
100 tập tin đã thay đổi với 2764 bổ sung470 xóa
  1. 2 2
      README.md
  2. 1 1
      src/PixiDocks
  3. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/IReadOnlyShapeVectorData.cs
  4. 13 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/Shapes/IReadOnlyTextData.cs
  5. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MergeNode.cs
  6. 32 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Node.cs
  7. 17 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs
  8. 10 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/PathVectorData.cs
  9. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/PointsVectorData.cs
  10. 21 6
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/ShapeVectorData.cs
  11. 231 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs
  12. 6 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RasterizeShapeNode.cs
  13. 2 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RemoveClosePointsNode.cs
  14. 71 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextNode.cs
  15. 51 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextOnPathNode.cs
  16. 13 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs
  17. 7 4
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelectedArea_Change.cs
  18. 153 0
      src/PixiEditor.ChangeableDocument/Changes/Drawing/PreviewShiftLayers_UpdateableChange.cs
  19. 68 51
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ShiftLayer_UpdateableChange.cs
  20. 18 11
      src/PixiEditor.ChangeableDocument/Changes/Drawing/TransformSelected_UpdateableChange.cs
  21. 87 7
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/NodeOperations.cs
  22. 11 1
      src/PixiEditor.ChangeableDocument/Changes/Root/FlipImage_Change.cs
  23. 9 2
      src/PixiEditor.ChangeableDocument/Changes/Root/RotateImage_Change.cs
  24. 21 14
      src/PixiEditor.ChangeableDocument/Changes/Structure/CreateStructureMember_Change.cs
  25. 25 5
      src/PixiEditor.ChangeableDocument/Changes/Structure/DuplicateFolder_Change.cs
  26. 12 3
      src/PixiEditor.ChangeableDocument/Changes/Structure/DuplicateLayer_Change.cs
  27. 99 13
      src/PixiEditor.ChangeableDocument/Changes/Structure/MoveStructureMember_Change.cs
  28. 1 0
      src/PixiEditor.ChangeableDocument/Changes/Structure/RasterizeMember_Change.cs
  29. 46 0
      src/PixiEditor.ChangeableDocument/Changes/Structure/SetLowDpiRendering_Change.cs
  30. 73 0
      src/PixiEditor.ChangeableDocument/Changes/Vectors/ConvertToCurve_Change.cs
  31. 11 10
      src/PixiEditor.ChangeableDocument/Changes/Vectors/SetShapeGeometry_UpdateableChange.cs
  32. 1 0
      src/PixiEditor.Extensions/UI/Overlays/OverlayPointerArgs.cs
  33. 4 1
      src/PixiEditor.SVG/Elements/SvgGroup.cs
  34. 6 1
      src/PixiEditor.SVG/Elements/SvgPrimitive.cs
  35. 51 0
      src/PixiEditor.SVG/Elements/SvgText.cs
  36. 8 0
      src/PixiEditor.SVG/Enums/SvgFontStyle.cs
  37. 9 0
      src/PixiEditor.SVG/Enums/SvgFontWeight.cs
  38. 1 0
      src/PixiEditor.SVG/Features/IFillable.cs
  39. 8 0
      src/PixiEditor.SVG/Features/IOpacity.cs
  40. 154 32
      src/PixiEditor.SVG/StyleContext.cs
  41. 16 13
      src/PixiEditor.SVG/SvgDocument.cs
  42. 24 23
      src/PixiEditor.SVG/SvgElement.cs
  43. 14 12
      src/PixiEditor.SVG/SvgParser.cs
  44. 20 3
      src/PixiEditor.SVG/SvgProperty.cs
  45. 35 15
      src/PixiEditor.SVG/Units/SvgNumericUnit.cs
  46. 142 0
      src/PixiEditor.SVG/Units/SvgNumericUnits.cs
  47. 73 0
      src/PixiEditor.SVG/Units/SvgStyleUnit.cs
  48. 98 4
      src/PixiEditor.SVG/Units/SvgTransformUnit.cs
  49. 8 2
      src/PixiEditor.UI.Common/Accents/Base.axaml
  50. BIN
      src/PixiEditor.UI.Common/Fonts/PixiPerfect.ttf
  51. 49 29
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml
  52. 16 1
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml.cs
  53. 0 1
      src/PixiEditor/App.axaml
  54. 26 3
      src/PixiEditor/Data/Configs/ToolSetsConfig.json
  55. 24 3
      src/PixiEditor/Data/Localization/Languages/en.json
  56. 18 0
      src/PixiEditor/Helpers/Converters/FontFamilyNameToAvaloniaFontFamily.cs
  57. 19 9
      src/PixiEditor/Helpers/DocumentViewModelBuilder.cs
  58. 4 2
      src/PixiEditor/Helpers/Extensions/PixiParserDocumentEx.cs
  59. 4 0
      src/PixiEditor/Helpers/ServiceCollectionHelpers.cs
  60. 2 2
      src/PixiEditor/Helpers/SupportedFilesHelper.cs
  61. 8 0
      src/PixiEditor/Helpers/ThemeResources.cs
  62. 1 1
      src/PixiEditor/Initialization/ClassicDesktopEntry.cs
  63. 18 3
      src/PixiEditor/Models/Controllers/ClipboardController.cs
  64. 44 0
      src/PixiEditor/Models/Controllers/FontLibrary.cs
  65. 3 3
      src/PixiEditor/Models/Controllers/InputDevice/MouseInputFilter.cs
  66. 10 6
      src/PixiEditor/Models/Controllers/InputDevice/MouseOnCanvasEventArgs.cs
  67. 3 3
      src/PixiEditor/Models/Controllers/ShortcutController.cs
  68. 68 51
      src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs
  69. 34 2
      src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs
  70. 2 0
      src/PixiEditor/Models/DocumentModels/Public/DocumentEventsModule.cs
  71. 17 6
      src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs
  72. 14 1
      src/PixiEditor/Models/DocumentModels/Public/DocumentStructureModule.cs
  73. 6 1
      src/PixiEditor/Models/DocumentModels/Public/DocumentToolsModule.cs
  74. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/DrawableShapeToolExecutor.cs
  75. 6 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/IQuickToolSwitchable.cs
  76. 6 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITextOverlayEvents.cs
  77. 9 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITransformDraggedEvent.cs
  78. 6 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITransformStoppedEvent.cs
  79. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITransformableExecutor.cs
  80. 4 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/LineExecutor.cs
  81. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/MagicWandToolExecutor.cs
  82. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs
  83. 0 74
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/ShiftLayerExecutor.cs
  84. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/SimpleShapeToolExecutor.cs
  85. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformReferenceLayerExecutor.cs
  86. 180 4
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedExecutor.cs
  87. 10 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs
  88. 2 2
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorLineToolExecutor.cs
  89. 268 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorTextToolExecutor.cs
  90. 2 0
      src/PixiEditor/Models/Files/IoFileType.cs
  91. 18 0
      src/PixiEditor/Models/Files/OtfFileType.cs
  92. 2 0
      src/PixiEditor/Models/Files/SvgFileType.cs
  93. 18 0
      src/PixiEditor/Models/Files/TtfFileType.cs
  94. 6 2
      src/PixiEditor/Models/Handlers/IDocument.cs
  95. 1 0
      src/PixiEditor/Models/Handlers/IDocumentOperations.cs
  96. 1 0
      src/PixiEditor/Models/Handlers/ILayerHandler.cs
  97. 16 0
      src/PixiEditor/Models/Handlers/ITextOverlayHandler.cs
  98. 6 3
      src/PixiEditor/Models/Handlers/IToolHandler.cs
  99. 3 0
      src/PixiEditor/Models/Handlers/IToolsHandler.cs
  100. 15 0
      src/PixiEditor/Models/Handlers/Toolbars/ITextToolbar.cs

+ 2 - 2
README.md

@@ -12,9 +12,9 @@
 
 ### Check out our website [pixieditor.net](https://pixieditor.net) and [PixiEditor Forum](https://forum.pixieditor.net/)
 
-# Contributions temporarily freezed!
+# Feature Contributions temporarily freezed!
 
-PixiEditor is undergoing massive changes, master branch is unstable. We will not accept any contributions at the moment, until version 2.0 comes out.
+PixiEditor is undergoing massive changes, master branch is unstable. We will not accept any feature contributions at the moment, until version 2.0 comes out. Feel free to fix bugs though. But before you do, let us know on [Discord](https://discord.gg/qSRMYmq), since we already might've fixed them.
 
 ## About PixiEditor
 

+ 1 - 1
src/PixiDocks

@@ -1 +1 @@
-Subproject commit b83ba013241e6d6b6d280eea8836e49c5c6b9f81
+Subproject commit 47107d7dc284e04ed92e4c470a6ed2f972e5d9cd

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/IReadOnlyShapeVectorData.cs

@@ -1,5 +1,6 @@
 using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Vector;
 using Drawie.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
@@ -14,4 +15,5 @@ public interface IReadOnlyShapeVectorData
     public RectD GeometryAABB { get; }
     public RectD TransformedAABB { get; }
     public ShapeCorners TransformationCorners { get; }
+    public VectorPath ToPath();
 }

+ 13 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/Shapes/IReadOnlyTextData.cs

@@ -0,0 +1,13 @@
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces.Shapes;
+
+public interface IReadOnlyTextData
+{
+    public string Text { get; }
+    public VecD Position { get; }
+    public Font ConstructFont();
+    public double Spacing { get; }
+    public double MaxWidth { get; }
+}

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MergeNode.cs

@@ -54,12 +54,12 @@ public class MergeNode : RenderNode
         if (Bottom.Value != null && Top.Value != null)
         {
             int saved = target.Canvas.SaveLayer();
-            Bottom.Value.Paint(context, target);
+            Bottom.Value?.Paint(context, target);
 
             paint.BlendMode = RenderContext.GetDrawingBlendMode(BlendMode.Value);
             target.Canvas.SaveLayer(paint);
             
-            Top.Value.Paint(context, target);
+            Top.Value?.Paint(context, target);
             target.Canvas.RestoreToCount(saved);
             return;
         }

+ 32 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Node.cs

@@ -44,7 +44,7 @@ public abstract class Node : IReadOnlyNode, IDisposable
 
     protected internal bool IsDisposed => _isDisposed;
     private bool _isDisposed;
-    
+
     public void Execute(RenderContext context)
     {
         ExecuteInternal(context);
@@ -112,6 +112,36 @@ public abstract class Node : IReadOnlyNode, IDisposable
         }
     }
 
+    public void TraverseBackwards(Func<IReadOnlyNode, IReadOnlyNode?, IInputProperty, bool> action)
+    {
+        var visited = new HashSet<IReadOnlyNode>();
+        var queueNodes = new Queue<(IReadOnlyNode, IReadOnlyNode, IInputProperty)>();
+        queueNodes.Enqueue((this, null, null));
+
+        while (queueNodes.Count > 0)
+        {
+            var node = queueNodes.Dequeue();
+
+            if (!visited.Add((node.Item1)))
+            {
+                continue;
+            }
+
+            if (!action(node.Item1, node.Item2, node.Item3))
+            {
+                return;
+            }
+
+            foreach (var inputProperty in node.Item1.InputProperties)
+            {
+                if (inputProperty.Connection != null)
+                {
+                    queueNodes.Enqueue((inputProperty.Connection.Node, node.Item1, inputProperty));
+                }
+            }
+        }
+    }
+
     public void TraverseBackwards(Func<IReadOnlyNode, bool> action)
     {
         var visited = new HashSet<IReadOnlyNode>();
@@ -461,7 +491,7 @@ public abstract class Node : IReadOnlyNode, IDisposable
     {
         return new None();
     }
-    
+
     private void InvokeConnectionsChanged()
     {
         ConnectionsChanged?.Invoke();

+ 17 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs

@@ -5,6 +5,7 @@ using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 
@@ -67,6 +68,22 @@ public abstract class RenderNode : Node, IPreviewRenderable, IHighDpiRenderNode
         return textureCache.RequestTexture(id, size, processingCs, clear);
     }
 
+    public override void SerializeAdditionalData(Dictionary<string, object> additionalData)
+    {
+        base.SerializeAdditionalData(additionalData);
+        additionalData["AllowHighDpiRendering"] = AllowHighDpiRendering;
+    }
+
+    internal override OneOf<None, IChangeInfo, List<IChangeInfo>> DeserializeAdditionalData(IReadOnlyDocument target, IReadOnlyDictionary<string, object> data)
+    {
+        base.DeserializeAdditionalData(target, data);
+
+        if(data.TryGetValue("AllowHighDpiRendering", out var value))
+            AllowHighDpiRendering = (bool)value;
+
+        return new None();
+    }
+
     public override void Dispose()
     {
         base.Dispose();

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

@@ -11,7 +11,7 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 public class PathVectorData : ShapeVectorData, IReadOnlyPathData
 {
     public VectorPath Path { get; set; }
-    public override RectD GeometryAABB => Path.TightBounds;
+    public override RectD GeometryAABB => Path?.TightBounds ?? RectD.Empty;
     public override RectD VisualAABB => GeometryAABB.Inflate(StrokeWidth / 2);
 
     public override ShapeCorners TransformationCorners =>
@@ -24,6 +24,10 @@ public class PathVectorData : ShapeVectorData, IReadOnlyPathData
     public PathVectorData(VectorPath path)
     {
         Path = path;
+        if (path == null)
+        {
+            Path = new VectorPath();
+        }
     }
 
     public override void RasterizeGeometry(Canvas canvas)
@@ -38,6 +42,11 @@ public class PathVectorData : ShapeVectorData, IReadOnlyPathData
 
     private void Rasterize(Canvas canvas, bool applyTransform)
     {
+        if(Path == null)
+        {
+            return;
+        }
+
         int num = 0;
         if (applyTransform)
         {

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/PointsVectorData.cs

@@ -3,6 +3,7 @@ using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Backend.Core.Vector;
 using Drawie.Numerics;
+using Drawie.Numerics.Helpers;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 
@@ -47,8 +48,7 @@ public class PointsVectorData : ShapeVectorData
             canvas.SetMatrix(final);
         }
 
-        canvas.DrawPoints(PointMode.Points, Points.Select(p => new VecF((float)p.X, (float)p.Y)).ToArray(),
-            paint);
+        canvas.DrawPoints(PointMode.Points, Points.ToVecFArray(), paint);
 
         if (applyTransform)
         {

+ 21 - 6
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/ShapeVectorData.cs

@@ -11,18 +11,31 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 
 public abstract class ShapeVectorData : ICacheable, ICloneable, IReadOnlyShapeVectorData
 {
-    public Matrix3X3 TransformationMatrix { get; set; } = Matrix3X3.Identity; 
-    
+    private float strokeWidth = 0;
+
+    public Matrix3X3 TransformationMatrix { get; set; } = Matrix3X3.Identity;
+
     public Color StrokeColor { get; set; } = Colors.White;
     public Color FillColor { get; set; } = Colors.White;
-    public float StrokeWidth { get; set; } = 1;
+
+    public float StrokeWidth
+    {
+        get => strokeWidth;
+        set
+        {
+            strokeWidth = value;
+            OnStrokeWidthChanged();
+        }
+    }
+    
     public bool Fill { get; set; } = true;
-    public abstract RectD GeometryAABB { get; } 
+
+    public abstract RectD GeometryAABB { get; }
     public abstract RectD VisualAABB { get; }
     public RectD TransformedAABB => new ShapeCorners(GeometryAABB).WithMatrix(TransformationMatrix).AABBBounds;
     public RectD TransformedVisualAABB => new ShapeCorners(VisualAABB).WithMatrix(TransformationMatrix).AABBBounds;
-    public abstract ShapeCorners TransformationCorners { get; } 
-    
+    public abstract ShapeCorners TransformationCorners { get; }
+
     protected void ApplyTransformTo(Canvas canvas)
     {
         Matrix3X3 canvasMatrix = canvas.TotalMatrix;
@@ -46,6 +59,8 @@ public abstract class ShapeVectorData : ICacheable, ICloneable, IReadOnlyShapeVe
     }
 
     protected virtual void AdjustCopy(ShapeVectorData copy) { }
+    
+    protected virtual void OnStrokeWidthChanged() { }
 
     public override int GetHashCode()
     {

+ 231 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs

@@ -0,0 +1,231 @@
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces.Shapes;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+
+public class TextVectorData : ShapeVectorData, IReadOnlyTextData
+{
+    private string text;
+    private Font font = Font.CreateDefault();
+    private double? spacing = null;
+    private double strokeWidth = 1;
+    private VectorPath? path;
+
+    public string Text
+    {
+        get => text;
+        set
+        {
+            text = value;
+            richText = new RichText(value) { Spacing = Spacing, MaxWidth = MaxWidth, StrokeWidth = StrokeWidth };
+
+            lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+
+    public VecD Position { get; set; }
+
+
+    public double MaxWidth { get; set; } = double.MaxValue;
+
+    public Font Font
+    {
+        get => font;
+        set
+        {
+            if (value != null)
+            {
+                value.Changed -= FontChanged;
+            }
+
+            font = value;
+            if (value != null)
+            {
+                value.Changed += FontChanged;
+            }
+
+            lastBounds = richText.MeasureBounds(value);
+        }
+    }
+
+    private void FontChanged()
+    {
+        if (richText == null)
+        {
+            return;
+        }
+
+        lastBounds = richText.MeasureBounds(Font);
+    }
+
+    public Font ConstructFont()
+    {
+        Font newFont = Font.FromFontFamily(Font.Family);
+        newFont.Size = Font.Size;
+        newFont.Edging = Font.Edging;
+        newFont.SubPixel = Font.SubPixel;
+        newFont.Bold = Font.Bold;
+        newFont.Italic = Font.Italic;
+
+        return newFont;
+    }
+
+    double IReadOnlyTextData.Spacing => Spacing ?? Font.Size;
+
+    public double? Spacing
+    {
+        get => spacing;
+        set
+        {
+            spacing = value;
+            richText.Spacing = value;
+            lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+    
+    public bool AntiAlias { get; set; } = true;
+
+    protected override void OnStrokeWidthChanged()
+    {
+        if(richText == null)
+        {
+            return;
+        }
+
+        richText.StrokeWidth = StrokeWidth;
+        lastBounds = richText.MeasureBounds(Font);
+    }
+
+    public override RectD GeometryAABB
+    {
+        get
+        {
+            return lastBounds.Offset(Position);
+        }
+    }
+
+    public override ShapeCorners TransformationCorners =>
+        new ShapeCorners(GeometryAABB).WithMatrix(TransformationMatrix);
+
+    public override RectD VisualAABB => GeometryAABB;
+
+    public VectorPath? Path
+    {
+        get => path;
+        set
+        {
+            path = value;
+            // TODO: properly calculate bounds
+            //lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+
+    public FontFamilyName? MissingFontFamily { get; set; }
+    public string MissingFontText { get; set; }
+
+    private RichText richText;
+    private RectD lastBounds;
+    private double _spacing;
+
+    public TextVectorData()
+    {
+
+    }
+
+    public TextVectorData(string text)
+    {
+        Text = text;
+    }
+
+
+    public override VectorPath ToPath()
+    {
+        var path = richText.ToPath(Font);
+        path.Offset(Position);
+
+        return path;
+    }
+
+    public override void RasterizeGeometry(Canvas canvas)
+    {
+        Rasterize(canvas, false);
+    }
+
+    public override void RasterizeTransformed(Canvas canvas)
+    {
+        Rasterize(canvas, true);
+    }
+
+    private void Rasterize(Canvas canvas, bool applyTransform)
+    {
+        int num = 0;
+        if (applyTransform)
+        {
+            num = canvas.Save();
+            ApplyTransformTo(canvas);
+        }
+
+        using Paint paint = new Paint() { IsAntiAliased = AntiAlias };
+
+        richText.Fill = Fill;
+        richText.FillColor = FillColor;
+        richText.StrokeColor = StrokeColor;
+        richText.StrokeWidth = StrokeWidth;
+        richText.Spacing = Spacing;
+
+        if (MissingFontFamily != null)
+        {
+            paint.Color = Fill ? FillColor : StrokeColor;
+            canvas.DrawText($"{MissingFontText}: " + MissingFontFamily.Value.Name, Position, Font, paint);
+        }
+        else
+        {
+            PaintText(canvas, paint);
+        }
+
+        if (applyTransform)
+        {
+            canvas.RestoreToCount(num);
+        }
+    }
+
+    private void PaintText(Canvas canvas, Paint paint)
+    {
+        richText.Paint(canvas, Position, Font, paint, Path);
+    }
+
+    public override bool IsValid()
+    {
+        return !string.IsNullOrEmpty(Text);
+    }
+
+    public override int GetCacheHash()
+    {
+        return HashCode.Combine(Text, Position, Font, StrokeColor, FillColor, StrokeWidth, TransformationMatrix);
+    }
+
+    protected override void AdjustCopy(ShapeVectorData copy)
+    {
+        if (copy is TextVectorData textData)
+        {
+            textData.Font = Font.FromFontFamily(Font.Family);
+            textData.Font.Size = Font.Size;
+            textData.Font.Edging = Font.Edging;
+            textData.Font.SubPixel = Font.SubPixel;
+            textData.Font.Bold = Font.Bold;
+            textData.Font.Italic = Font.Italic;
+
+            textData.lastBounds = lastBounds;
+        }
+    }
+
+    public override int CalculateHash()
+    {
+        return GetCacheHash();
+    }
+}

+ 6 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RasterizeShapeNode.cs

@@ -12,11 +12,12 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
 public class RasterizeShapeNode : RenderNode
 {
     public InputProperty<ShapeVectorData> Data { get; }
-
+    public InputProperty<bool> HighDpiRendering { get; }
 
     public RasterizeShapeNode()
     {
         Data = CreateInput<ShapeVectorData>("Points", "SHAPE", null);
+        HighDpiRendering = CreateInput<bool>("High DPI Rendering", "HIGH_DPI_RENDERING", true);
     }
 
     protected override void OnPaint(RenderContext context, DrawingSurface surface)
@@ -25,11 +26,14 @@ public class RasterizeShapeNode : RenderNode
 
         if (shape == null || !shape.IsValid())
             return;
-        
+
+        AllowHighDpiRendering = HighDpiRendering.Value;
+
         shape.RasterizeTransformed(surface.Canvas);
     }
 
     public override Node CreateCopy() => new RasterizeShapeNode();
+
     public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
     {
         return Data?.Value?.TransformedAABB;

+ 2 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RemoveClosePointsNode.cs

@@ -26,6 +26,7 @@ public class RemoveClosePointsNode : ShapeNode<PointsVectorData>
         var data = Input.Value;
 
         var distance = MinDistance.Value;
+        var minDistanceSquared = distance * distance;
 
         if (distance == 0 || data == null || data.Points == null)
         {
@@ -34,9 +35,6 @@ public class RemoveClosePointsNode : ShapeNode<PointsVectorData>
 
         var availablePoints = data.Points.Distinct().ToList();
         List<VecD> newPoints = new List<VecD>();
-        
-        var minDistance = MinDistance.Value;
-        var documentSize = context.DocumentSize;
 
         var random = new Random(Seed.Value);
         while (availablePoints.Count > 1)
@@ -55,7 +53,7 @@ public class RemoveClosePointsNode : ShapeNode<PointsVectorData>
             continue;
 
             bool InRange(VecD other) =>
-                (other - point).Length <= minDistance;
+                (other - point).LengthSquared <= minDistanceSquared;
         }
 
         if (availablePoints.Count == 1)

+ 71 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextNode.cs

@@ -0,0 +1,71 @@
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("Text")]
+public class TextNode : ShapeNode<TextVectorData>
+{
+    public InputProperty<string> Text { get; }
+    public InputProperty<VecD> TextPosition { get; }
+    public InputProperty<FontFamilyName> FontFamily { get; }
+    public InputProperty<double> FontSize { get; }
+
+    private string lastText = "";
+    private VecD lastPosition = new VecD();
+    private FontFamilyName lastFontFamily = new FontFamilyName();
+    private double lastFontSize = 12d;
+    private VectorPath? lastPath;
+
+    private TextVectorData? cachedData;
+    public TextNode()
+    {
+        Text = CreateInput("Text", "TEXT_LABEL", "");
+        TextPosition = CreateInput("Position", "POSITION", new VecD());
+        FontFamily = CreateInput("FontFamily", "FONT_LABEL", new FontFamilyName());
+        FontSize = CreateInput("FontSize", "FONT_SIZE_LABEL", 12d);
+    }
+    
+    protected override TextVectorData? GetShapeData(RenderContext context)
+    {
+        string text = Text.Value;
+        VecD position = TextPosition.Value;
+        FontFamilyName fontFamily = FontFamily.Value;
+        double fontSize = FontSize.Value;
+
+        if (text == lastText && position == lastPosition && fontFamily.Equals(lastFontFamily) && fontSize == lastFontSize)
+        {
+            return cachedData;
+        }
+        
+        lastText = text;
+        lastPosition = position;
+        lastFontFamily = fontFamily;
+        lastFontSize = fontSize;
+
+        Font font = Font.FromFontFamily(fontFamily);
+        if(font == null)
+        {
+            font = Font.CreateDefault();
+        }
+        
+        font.Size = fontSize;
+        
+        cachedData = new TextVectorData()
+        {
+            Text = text,
+            Position = position,
+            Font = font,
+        };
+        
+        return cachedData;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new TextNode();
+    }
+}

+ 51 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextOnPathNode.cs

@@ -0,0 +1,51 @@
+using Drawie.Backend.Core.Vector;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("TextOnPath")]
+public class TextOnPathNode : Node
+{
+    public InputProperty<TextVectorData> TextData { get; }
+    public InputProperty<ShapeVectorData> PathData { get; }
+
+    public OutputProperty<TextVectorData> Output { get; }
+
+    private VectorPath lastPath;
+
+    public TextOnPathNode()
+    {
+        TextData = CreateInput<TextVectorData>("Text", "TEXT_LABEL", null);
+        PathData = CreateInput<ShapeVectorData>("Path", "SHAPE_LABEL", null);
+
+        Output = CreateOutput<TextVectorData>("Output", "TEXT_LABEL", null);
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        var textData = TextData.Value;
+        var pathData = PathData.Value;
+
+        if (textData == null || pathData == null || !textData.IsValid() || !pathData.IsValid())
+        {
+            Output.Value = null;
+            return;
+        }
+
+        var cloned = (TextVectorData)textData.Clone();
+
+        lastPath?.Dispose();
+        lastPath = pathData.ToPath();
+        lastPath.Transform(pathData.TransformationMatrix);
+
+        cloned.Path = lastPath;
+
+        Output.Value = cloned;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new TextOnPathNode();
+    }
+}

+ 13 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs

@@ -17,6 +17,7 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 [NodeInfo("VectorLayer")]
 public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorNode, IRasterizable
 {
+    public OutputProperty<ShapeVectorData> Shape { get; }
     public Matrix3X3 TransformationMatrix
     {
         get => ShapeData?.TransformationMatrix ?? Matrix3X3.Identity;
@@ -31,7 +32,11 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
         }
     }
 
-    public ShapeVectorData? ShapeData { get; set; }
+    public ShapeVectorData? ShapeData
+    {
+        get => Shape.Value;
+        set => Shape.Value = value;
+    }
     IReadOnlyShapeVectorData IReadOnlyVectorNode.ShapeData => ShapeData;
 
 
@@ -43,6 +48,7 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
     public VectorLayerNode()
     {
         AllowHighDpiRendering = true;
+        Shape = CreateOutput<ShapeVectorData>("Shape", "SHAPE", null);
     }
     
     protected override VecI GetTargetSize(RenderContext ctx)
@@ -167,6 +173,11 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
 
     public override Node CreateCopy()
     {
-        return new VectorLayerNode() { ShapeData = (ShapeVectorData?)ShapeData?.Clone(), ClipToPreviousMember = this.ClipToPreviousMember };
+        return new VectorLayerNode()
+        {
+            ShapeData = (ShapeVectorData?)ShapeData?.Clone(),
+            ClipToPreviousMember = this.ClipToPreviousMember,
+            AllowHighDpiRendering = this.AllowHighDpiRendering
+        };
     }
 }

+ 7 - 4
src/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelectedArea_Change.cs

@@ -1,25 +1,28 @@
 using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Vector;
 using Drawie.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changes.Drawing;
 internal class ClearSelectedArea_Change : Change
 {
+    private VectorPath clearArea;
     private readonly Guid memberGuid;
     private readonly bool drawOnMask;
     private CommittedChunkStorage? savedChunks;
     private int frame;
 
     [GenerateMakeChangeAction]
-    public ClearSelectedArea_Change(Guid memberGuid, bool drawOnMask, int frame)
+    public ClearSelectedArea_Change(Guid memberGuid, VectorPath clearArea, bool drawOnMask, int frame)
     {
         this.memberGuid = memberGuid;
         this.drawOnMask = drawOnMask;
+        this.clearArea = clearArea;
         this.frame = frame;
     }
 
     public override bool InitializeAndValidate(Document target)
     {
-        return !target.Selection.SelectionPath.IsEmpty && DrawingChangeHelper.IsValidForDrawing(target, memberGuid, drawOnMask);
+        return clearArea is { IsEmpty: false } && DrawingChangeHelper.IsValidForDrawing(target, memberGuid, drawOnMask);
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
@@ -29,10 +32,10 @@ internal class ClearSelectedArea_Change : Change
 
         var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask, frame);
 
-        RectD bounds = target.Selection.SelectionPath.Bounds;
+        RectD bounds = clearArea.Bounds;
         RectI intBounds = (RectI)bounds.Intersect(new RectD(0, 0, target.Size.X, target.Size.Y)).RoundOutwards();
 
-        image.EnqueueClearPath(target.Selection.SelectionPath, intBounds);
+        image.EnqueueClearPath(clearArea, intBounds);
         var affArea = image.FindAffectedArea();
         savedChunks = new(image, affArea.Chunks);
         image.CommitChanges();

+ 153 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/PreviewShiftLayers_UpdateableChange.cs

@@ -0,0 +1,153 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.ChangeInfos.Vectors;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+
+internal class PreviewShiftLayers_UpdateableChange : InterruptableUpdateableChange
+{
+    private List<Guid> layerGuids;
+    private VecD delta;
+    private Dictionary<Guid, ShapeVectorData> originalShapes;
+
+    private int frame;
+
+    [GenerateUpdateableChangeActions]
+    public PreviewShiftLayers_UpdateableChange(List<Guid> layerGuids, VecD delta, int frame)
+    {
+        this.delta = delta;
+        this.layerGuids = layerGuids;
+        this.frame = frame;
+    }
+
+    public override bool InitializeAndValidate(Document target)
+    {
+        if (layerGuids.Count == 0)
+        {
+            return false;
+        }
+
+        layerGuids = target.ExtractLayers(layerGuids);
+
+        foreach (var layer in layerGuids)
+        {
+            if (!target.HasMember(layer)) return false;
+        }
+
+        originalShapes = new Dictionary<Guid, ShapeVectorData>();
+
+        foreach (var layerGuid in layerGuids)
+        {
+            var layer = target.FindMemberOrThrow<LayerNode>(layerGuid);
+
+            if (layer is VectorLayerNode transformableObject)
+            {
+                originalShapes[layerGuid] = transformableObject.ShapeData;
+                transformableObject.ShapeData = null;
+            }
+        }
+
+        return true;
+    }
+
+    [UpdateChangeMethod]
+    public void Update(VecD delta)
+    {
+        this.delta = delta;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        List<IChangeInfo> changes = new List<IChangeInfo>();
+        foreach (var layerGuid in layerGuids)
+        {
+            var layer = target.FindMemberOrThrow<LayerNode>(layerGuid);
+
+            if (layer is ImageLayerNode)
+            {
+                var area = ShiftLayerHelper.DrawShiftedLayer(target, layerGuid, true, (VecI)delta, frame);
+                changes.Add(new LayerImageArea_ChangeInfo(layerGuid, area));
+            }
+            else if (layer is VectorLayerNode vectorLayer)
+            {
+                StrokeJoin join = StrokeJoin.Miter;
+                StrokeCap cap = StrokeCap.Butt;
+                
+                (vectorLayer.ShapeData as PathVectorData)?.Path.Dispose();
+
+                var originalShape = originalShapes[layerGuid];
+
+                var path = originalShape.ToPath();
+
+                if (originalShape is PathVectorData shape)
+                {
+                    join = shape.StrokeLineJoin;
+                    cap = shape.StrokeLineCap;
+                }
+
+                VecD mappedDelta = originalShape.TransformationMatrix.Invert().MapVector((float)delta.X, (float)delta.Y);
+                
+                var finalMatrix = Matrix3X3.CreateTranslation((float)mappedDelta.X, (float)mappedDelta.Y);
+
+                path.AddPath(path, finalMatrix, AddPathMode.Append);
+
+                var newShapeData = new PathVectorData(path)
+                {
+                    StrokeWidth = originalShape.StrokeWidth,
+                    StrokeColor = originalShape.StrokeColor,
+                    FillColor = originalShape.FillColor,
+                    Fill = originalShape.Fill,
+                    TransformationMatrix = originalShape.TransformationMatrix,
+                    StrokeLineJoin = join,
+                    StrokeLineCap = cap
+                };
+                
+                vectorLayer.ShapeData = newShapeData;
+                changes.Add(new VectorShape_ChangeInfo(layerGuid, ShiftLayer_UpdateableChange.AffectedAreaFromBounds(target, layerGuid, frame)));
+            }
+        }
+
+        return changes;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
+        out bool ignoreInUndo)
+    {
+        ignoreInUndo = true;
+        return RevertPreview(target);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        return RevertPreview(target);
+    }
+
+    private OneOf<None, IChangeInfo, List<IChangeInfo>> RevertPreview(Document target)
+    {
+        List<IChangeInfo> changes = new List<IChangeInfo>();
+        foreach (var layerGuid in layerGuids)
+        {
+            var layer = target.FindMemberOrThrow<LayerNode>(layerGuid);
+
+            if (layer is ImageLayerNode imgLayer)
+            {
+                var image = imgLayer.GetLayerImageAtFrame(frame);
+                var affected = image.FindAffectedArea();
+                image.CancelChanges();
+                changes.Add(new LayerImageArea_ChangeInfo(layerGuid, affected));
+            }
+            else if (layer is VectorLayerNode transformableObject)
+            {
+                (transformableObject.ShapeData as PathVectorData)?.Path.Dispose();
+                transformableObject.ShapeData = originalShapes[layerGuid];
+            }
+        }
+
+        return changes;
+    }
+}

+ 68 - 51
src/PixiEditor.ChangeableDocument/Changes/Drawing/ShiftLayer_UpdateableChange.cs

@@ -2,25 +2,25 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using Drawie.Backend.Core.Numerics;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.ChangeInfos.Vectors;
 
 namespace PixiEditor.ChangeableDocument.Changes.Drawing;
 
-internal class ShiftLayer_UpdateableChange : UpdateableChange
+internal class ShiftLayer_UpdateableChange : Change
 {
     private List<Guid> layerGuids;
-    private bool keepOriginal;
-    private VecI delta;
+    private VecD delta;
     private Dictionary<Guid, CommittedChunkStorage?> originalLayerChunks = new();
+    private Dictionary<Guid, Matrix3X3> originalTransformations = new();
 
-    private List<IChangeInfo> _tempChanges = new();
     private int frame;
 
-    [GenerateUpdateableChangeActions]
-    public ShiftLayer_UpdateableChange(List<Guid> layerGuids, VecI delta, bool keepOriginal, int frame)
+    [GenerateMakeChangeAction]
+    public ShiftLayer_UpdateableChange(List<Guid> layerGuids, VecD delta, int frame)
     {
         this.delta = delta;
         this.layerGuids = layerGuids;
-        this.keepOriginal = keepOriginal;
         this.frame = frame;
     }
 
@@ -41,59 +41,38 @@ internal class ShiftLayer_UpdateableChange : UpdateableChange
         return true;
     }
 
-    [UpdateChangeMethod]
-    public void Update(VecI delta, bool keepOriginal)
-    {
-        this.delta = delta;
-        this.keepOriginal = keepOriginal;
-    }
-
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
         out bool ignoreInUndo)
     {
         originalLayerChunks = new Dictionary<Guid, CommittedChunkStorage?>();
+        originalTransformations = new Dictionary<Guid, Matrix3X3>();
         List<IChangeInfo> changes = new List<IChangeInfo>();
         foreach (var layerGuid in layerGuids)
         {
             var layer = target.FindMemberOrThrow<LayerNode>(layerGuid);
 
-            // TODO: This now does't crash, but ignores other layer types. Think how to handle this.
-            if (layer is not ImageLayerNode)
+            if (layer is ImageLayerNode)
             {
-                continue;
-            }
-
-            var area = ShiftLayerHelper.DrawShiftedLayer(target, layerGuid, keepOriginal, delta, frame);
-            var image = target.FindMemberOrThrow<ImageLayerNode>(layerGuid).GetLayerImageAtFrame(frame);
-
-            changes.Add(new LayerImageArea_ChangeInfo(layerGuid, area));
-
-            originalLayerChunks[layerGuid] = new(image, image.FindAffectedArea().Chunks);
-            image.CommitChanges();
-        }
+                var area = ShiftLayerHelper.DrawShiftedLayer(target, layerGuid, false, (VecI)delta, frame);
+                var image = target.FindMemberOrThrow<ImageLayerNode>(layerGuid).GetLayerImageAtFrame(frame);
 
-        ignoreInUndo = delta.TaxicabLength == 0;
-        return changes;
-    }
-
-    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
-    {
-        _tempChanges.Clear();
-
-        foreach (var layerGuid in layerGuids)
-        {
-            var layer = target.FindMemberOrThrow<LayerNode>(layerGuid);
+                changes.Add(new LayerImageArea_ChangeInfo(layerGuid, area));
 
-            if (layer is not ImageLayerNode)
+                originalLayerChunks[layerGuid] = new(image, image.FindAffectedArea().Chunks);
+                image.CommitChanges();
+            }
+            else if (layer is ITransformableObject transformableObject)
             {
-                continue;
+                originalTransformations[layerGuid] = transformableObject.TransformationMatrix;
+                AffectedArea affected = AffectedAreaFromBounds(target, layerGuid, frame);
+                transformableObject.TransformationMatrix = transformableObject.TransformationMatrix.PostConcat(
+                Matrix3X3.CreateTranslation((float)delta.X, (float)delta.Y));
+                changes.Add(new VectorShape_ChangeInfo(layerGuid, affected));
             }
-
-            var chunks = ShiftLayerHelper.DrawShiftedLayer(target, layerGuid, keepOriginal, delta, frame);
-            _tempChanges.Add(new LayerImageArea_ChangeInfo(layerGuid, chunks));
         }
 
-        return _tempChanges;
+        ignoreInUndo = delta.TaxicabLength == 0;
+        return changes;
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
@@ -103,15 +82,17 @@ internal class ShiftLayer_UpdateableChange : UpdateableChange
         {
             var layer = target.FindMemberOrThrow<LayerNode>(layerGuid);
 
-            if (layer is not ImageLayerNode)
+            if (layer is ImageLayerNode)
             {
-                continue;
+                var image = target.FindMemberOrThrow<ImageLayerNode>(layerGuid).GetLayerImageAtFrame(frame);
+                CommittedChunkStorage? originalChunks = originalLayerChunks[layerGuid];
+                var affected = DrawingChangeHelper.ApplyStoredChunksDisposeAndSetToNull(image, ref originalChunks);
+                changes.Add(new LayerImageArea_ChangeInfo(layerGuid, affected));
+            }
+            else if (layer is ITransformableObject transformableObject)
+            {
+                transformableObject.TransformationMatrix = originalTransformations[layerGuid];
             }
-
-            var image = target.FindMemberOrThrow<ImageLayerNode>(layerGuid).GetLayerImageAtFrame(frame);
-            CommittedChunkStorage? originalChunks = originalLayerChunks[layerGuid];
-            var affected = DrawingChangeHelper.ApplyStoredChunksDisposeAndSetToNull(image, ref originalChunks);
-            changes.Add(new LayerImageArea_ChangeInfo(layerGuid, affected));
         }
 
         return changes;
@@ -124,4 +105,40 @@ internal class ShiftLayer_UpdateableChange : UpdateableChange
             value?.Dispose();
         }
     }
+    
+    internal static AffectedArea AffectedAreaFromBounds(Document target, Guid layerGuid, int frame)
+    {
+        HashSet<VecI> chunks = new HashSet<VecI>();
+        
+        var layer = target.FindMemberOrThrow<LayerNode>(layerGuid);
+        if (layer is not VectorLayerNode vectorLayer)
+        {
+            return new AffectedArea();
+        }
+
+        RectD? bounds = vectorLayer.GetTightBounds(frame);
+        if (bounds is null)
+        {
+            return new AffectedArea();
+        }
+        
+        RectD boundsValue = bounds.Value;
+
+        int chunkSize = ChunkyImage.FullChunkSize;
+        
+        VecI start = new VecI((int)boundsValue.X / chunkSize, (int)boundsValue.Y / chunkSize);
+        VecI end = new VecI((int)(boundsValue.X + boundsValue.Width) / chunkSize, (int)(boundsValue.Y + boundsValue.Height) / chunkSize);
+        
+        HashSet<VecI> affectedChunks = new HashSet<VecI>();
+        
+        for (int x = start.X; x <= end.X; x++)
+        {
+            for (int y = start.Y; y <= end.Y; y++)
+            {
+                affectedChunks.Add(new VecI(x, y));
+            }
+        }
+        
+        return new AffectedArea(affectedChunks);
+    }
 }

+ 18 - 11
src/PixiEditor.ChangeableDocument/Changes/Drawing/TransformSelected_UpdateableChange.cs

@@ -9,6 +9,7 @@ using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Backend.Core.Vector;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 
 namespace PixiEditor.ChangeableDocument.Changes.Drawing;
 
@@ -118,21 +119,22 @@ internal class TransformSelected_UpdateableChange : InterruptableUpdateableChang
             }
             else if (layer is ITransformableObject transformable)
             {
-                SetTransformableMember(layer, member, transformable, tightBounds);
+                SetTransformableMember(layer.Id, member, transformable, tightBounds);
             }
         }
 
         return true;
     }
 
-    private void SetTransformableMember(StructureNode layer, MemberTransformationData member,
+    private void SetTransformableMember(Guid transformableId,
+        MemberTransformationData member,
         ITransformableObject transformable, RectD tightBounds)
     {
         member.OriginalBounds = tightBounds; 
         VecD posRelativeToMaster = member.OriginalBounds.Value.TopLeft - masterCorners.TopLeft;
 
         member.OriginalPos = (VecI)posRelativeToMaster;
-        member.AddTransformableObject(transformable, transformable.TransformationMatrix);
+        member.AddTransformableObject(transformableId, transformable.TransformationMatrix);
     }
 
     private void SetImageMember(Document target, MemberTransformationData member, RectD originalTightBounds,
@@ -255,7 +257,8 @@ internal class TransformSelected_UpdateableChange : InterruptableUpdateableChang
             }
             else if (member.IsTransformable)
             {
-                member.TransformableObject.TransformationMatrix = member.LocalMatrix;
+                var transformable = target.FindMemberOrThrow(member.MemberId) as ITransformableObject;
+                transformable.TransformationMatrix = member.LocalMatrix;
 
                 AffectedArea area = GetTranslationAffectedArea();
                 infos.Add(new TransformObject_ChangeInfo(member.MemberId, area));
@@ -290,7 +293,8 @@ internal class TransformSelected_UpdateableChange : InterruptableUpdateableChang
             }
             else if (member.IsTransformable)
             {
-                member.TransformableObject.TransformationMatrix = member.LocalMatrix;
+                var transformable = target.FindMemberOrThrow(member.MemberId) as ITransformableObject;
+                transformable.TransformationMatrix = member.LocalMatrix;
 
                 AffectedArea translationAffectedArea = GetTranslationAffectedArea();
                 var tmp = new AffectedArea(translationAffectedArea);
@@ -331,7 +335,8 @@ internal class TransformSelected_UpdateableChange : InterruptableUpdateableChang
             }
             else if (member.IsTransformable)
             {
-                member.TransformableObject.TransformationMatrix = member.OriginalMatrix!.Value;
+                var transformable = target.FindMemberOrThrow(member.MemberId) as ITransformableObject;
+                transformable.TransformationMatrix = member.OriginalMatrix!.Value;
 
                 //TODO this is probably wrong
                 AffectedArea area = GetTranslationAffectedArea();
@@ -409,8 +414,8 @@ class MemberTransformationData : IDisposable
     public Guid MemberId { get; }
     public ShapeCorners MemberCorners { get; init; }
 
-    public ITransformableObject? TransformableObject { get; private set; }
-    public Matrix3X3? OriginalMatrix { get; private set; }
+    public Guid TransformableObjectId { get; private set; }
+    public Matrix3X3? OriginalMatrix { get; set; }
 
     public CommittedChunkStorage? SavedChunks { get; set; }
     public VectorPath? OriginalPath { get; set; }
@@ -418,18 +423,19 @@ class MemberTransformationData : IDisposable
     public RectD? OriginalBounds { get; set; }
     public VecD? OriginalPos { get; set; }
     public bool IsImage => Image != null;
-    public bool IsTransformable => TransformableObject != null;
+    public bool IsTransformable => TransformableObjectId != default;
     public RectI? RoundedOriginalBounds => (RectI)OriginalBounds?.RoundOutwards();
     public Matrix3X3 LocalMatrix { get; set; }
+    public ShapeVectorData? OriginalShapeData { get; set; }
 
     public MemberTransformationData(Guid memberId)
     {
         MemberId = memberId;
     }
 
-    public void AddTransformableObject(ITransformableObject transformableObject, Matrix3X3 originalMatrix)
+    public void AddTransformableObject(Guid transformableObject, Matrix3X3 originalMatrix)
     {
-        TransformableObject = transformableObject;
+        TransformableObjectId = transformableObject;
         OriginalMatrix = new Matrix3X3?(originalMatrix);
     }
 
@@ -446,5 +452,6 @@ class MemberTransformationData : IDisposable
         OriginalPath?.Dispose();
         OriginalPath = null;
         SavedChunks?.Dispose();
+        OriginalShapeData = null;
     }
 }

+ 87 - 7
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/NodeOperations.cs

@@ -9,6 +9,7 @@ using PixiEditor.ChangeableDocument.Changes.Structure;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core.Shaders.Generation;
 using Drawie.Backend.Core.Surfaces;
+using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Context;
 
 namespace PixiEditor.ChangeableDocument.Changes.NodeGraph;
@@ -29,7 +30,7 @@ public static class NodeOperations
             INodeFactory factory = (INodeFactory)Activator.CreateInstance(factoryType);
             allFactories.Add(factory.NodeType, factory);
         }
-        
+
         nodeMap = new Dictionary<string, Type>();
         var nodeTypes = typeof(Node).Assembly.GetTypes().Where(x =>
                 x.IsSubclassOf(typeof(Node)) && x is { IsAbstract: false, IsInterface: false })
@@ -55,7 +56,7 @@ public static class NodeOperations
     {
         return nodeMap.TryGetValue(nodeUniqueName, out nodeType);
     }
-    
+
     public static Node CreateNode(Type nodeType, IReadOnlyDocument target, params object[] optionalParameters)
     {
         Node node = null;
@@ -71,12 +72,12 @@ public static class NodeOperations
         return node;
     }
 
-    public static List<ConnectProperty_ChangeInfo> AppendMember(
+    public static List<IChangeInfo> AppendMember(
         InputProperty<Painter?> parentInput,
         OutputProperty<Painter> toAddOutput,
         InputProperty<Painter> toAddInput, Guid memberId)
     {
-        List<ConnectProperty_ChangeInfo> changes = new();
+        List<IChangeInfo> changes = new();
         IOutputProperty? previouslyConnected = null;
         if (parentInput.Connection != null)
         {
@@ -94,7 +95,7 @@ public static class NodeOperations
 
         changes.Add(new ConnectProperty_ChangeInfo(memberId, parentInput.Node.Id,
             toAddOutput.InternalPropertyName, parentInput.InternalPropertyName));
-        
+
         return changes;
     }
 
@@ -113,6 +114,7 @@ public static class NodeOperations
             foreach (var input in connections)
             {
                 output.ConnectTo(input);
+
                 changes.Add(new ConnectProperty_ChangeInfo(output.Node.Id, input.Node.Id,
                     output.InternalPropertyName, input.InternalPropertyName));
             }
@@ -133,6 +135,84 @@ public static class NodeOperations
         return changes;
     }
 
+    public static List<IChangeInfo> AdjustPositionsAfterAppend(Node member, Node appendedTo, Node? previouslyConnected,
+        out Dictionary<Guid, VecD> originalPositions)
+    {
+        List<IChangeInfo> changes = new();
+        Dictionary<Guid, VecD> originalPositionDict = new();
+
+        member.Position = new VecD(appendedTo.Position.X - 250, appendedTo.Position.Y);
+
+        changes.Add(new NodePosition_ChangeInfo(member.Id, member.Position));
+
+        previouslyConnected?.TraverseBackwards((aNode, previousNode, _) =>
+        {
+            if (aNode is Node toMove)
+            {
+                originalPositionDict.Add(toMove.Id, toMove.Position);
+                var y = toMove.Position.Y;
+                toMove.Position = (previousNode?.Position ?? member.Position) - new VecD(250, 0);
+                toMove.Position = new VecD(toMove.Position.X, y);
+                changes.Add(new NodePosition_ChangeInfo(toMove.Id, toMove.Position));
+            }
+
+            return true;
+        });
+
+        originalPositions = originalPositionDict;
+        return changes;
+    }
+
+    public static List<IChangeInfo> AdjustPositionsBeforeAppend(Node member, Node appendedTo,
+        out Dictionary<Guid, VecD> originalPositions)
+    {
+        List<IChangeInfo> changes = new();
+        Dictionary<Guid, VecD> originalPositionDict = new();
+
+        member.TraverseBackwards((aNode, previousNode, _) =>
+        {
+            if (aNode is Node toMove)
+            {
+                originalPositionDict.Add(toMove.Id, toMove.Position);
+                var y = toMove.Position.Y;
+                VecD pos = member.Position + new VecD(250, 0);
+                if (previousNode != null)
+                {
+                    pos = previousNode.Position - new VecD(250, 0);
+                }
+
+                toMove.Position = pos;
+                toMove.Position = new VecD(toMove.Position.X, y);
+                changes.Add(new NodePosition_ChangeInfo(toMove.Id, toMove.Position));
+                
+                if(aNode == appendedTo) return false;
+            }
+
+            return true;
+        });
+
+        member.Position = new VecD(appendedTo.Position.X - 250, appendedTo.Position.Y);
+        changes.Add(new NodePosition_ChangeInfo(member.Id, member.Position));
+
+        originalPositions = originalPositionDict;
+        return changes;
+    }
+
+    public static List<IChangeInfo> RevertPositions(Dictionary<Guid, VecD> positions, IReadOnlyDocument target)
+    {
+        List<IChangeInfo> changes = new();
+        foreach (var (guid, position) in positions)
+        {
+            var node = target.FindNode(guid) as Node;
+            if (node == null) continue;
+
+            node.Position = position;
+            changes.Add(new NodePosition_ChangeInfo(guid, position));
+        }
+
+        return changes;
+    }
+
     public static ConnectionsData CreateConnectionsData(IReadOnlyNode node)
     {
         var originalOutputConnections = new Dictionary<PropertyConnection, List<PropertyConnection>>();
@@ -152,7 +232,7 @@ public static class NodeOperations
                 (new PropertyConnection(x.Node.Id, x.InternalPropertyName),
                     new PropertyConnection(x.Connection?.Node.Id, x.Connection?.InternalPropertyName)))
             .ToList();
-        
+
         return new ConnectionsData(originalOutputConnections, originalInputConnections);
     }
 
@@ -246,7 +326,7 @@ public static class NodeOperations
                     value = expressionVariable.GetConstant();
                 }
             }
-            
+
             changes.Add(new PropertyValueUpdated_ChangeInfo(copy.Id, input.InternalPropertyName, value));
         }
 

+ 11 - 1
src/PixiEditor.ChangeableDocument/Changes/Root/FlipImage_Change.cs

@@ -6,6 +6,7 @@ using Drawie.Backend.Core;
 using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
 
 namespace PixiEditor.ChangeableDocument.Changes.Root;
@@ -111,7 +112,16 @@ internal sealed class FlipImage_Change : Change
                         new LayerImageArea_ChangeInfo(member.Id, image.FindAffectedArea()));
                     image.CommitChanges();
                 }
-                // TODO: Add support for non-raster layers
+                else if (member is ITransformableObject transformableObject)
+                {
+                    RectD? tightBounds = member.GetTightBounds(frame);
+                    if(tightBounds == null) return;
+                    transformableObject.TransformationMatrix = transformableObject.TransformationMatrix.PostConcat(
+                        Matrix3X3.CreateScale(
+                            flipType == FlipType.Horizontal ? -1 : 1,
+                            flipType == FlipType.Vertical ? -1 : 1, 
+                            (float)tightBounds.Value.Center.X, (float)tightBounds.Value.Center.Y));
+                }
 
                 if (member.EmbeddedMask is not null)
                 {

+ 9 - 2
src/PixiEditor.ChangeableDocument/Changes/Root/RotateImage_Change.cs

@@ -6,6 +6,7 @@ using Drawie.Backend.Core;
 using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
 
 namespace PixiEditor.ChangeableDocument.Changes.Root;
@@ -172,8 +173,14 @@ internal sealed class RotateImage_Change : Change
                         });
                     }
                 }
-
-                // TODO: Add support for different Layer types
+                else if (member is ITransformableObject transformableObject)
+                {
+                    RectD? tightBounds = member.GetTightBounds(frame.Value);
+                    transformableObject.TransformationMatrix = transformableObject.TransformationMatrix.PostConcat(
+                        Matrix3X3.CreateRotation(
+                            RotationAngleToRadians(rotation),
+                            (float?)tightBounds?.Center.X ?? 0, (float?)tightBounds?.Center.Y ?? 0));
+                }
 
                 if (member.EmbeddedMask is null)
                     return;

+ 21 - 14
src/PixiEditor.ChangeableDocument/Changes/Structure/CreateStructureMember_Change.cs

@@ -1,4 +1,5 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
@@ -13,8 +14,9 @@ internal class CreateStructureMember_Change : Change
 
     private Guid parentGuid;
     private Type structureMemberOfType;
-    
+
     private ConnectionsData? connectionsData;
+    private Dictionary<Guid, VecD> originalPositions;
 
     [GenerateMakeChangeAction]
     public CreateStructureMember_Change(Guid parent, Guid newGuid,
@@ -27,25 +29,28 @@ internal class CreateStructureMember_Change : Change
 
     public override bool InitializeAndValidate(Document target)
     {
-        if(structureMemberOfType == null || structureMemberOfType.IsAbstract || structureMemberOfType.IsInterface || !structureMemberOfType.IsAssignableTo(typeof(StructureNode)))
+        if (structureMemberOfType == null || structureMemberOfType.IsAbstract || structureMemberOfType.IsInterface ||
+            !structureMemberOfType.IsAssignableTo(typeof(StructureNode)))
             return false;
-        
+
         return target.TryFindNode<Node>(parentGuid, out _);
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document document, bool firstApply,
         out bool ignoreInUndo)
     {
-        StructureNode member = (StructureNode)NodeOperations.CreateNode(structureMemberOfType, document); 
+        StructureNode member = (StructureNode)NodeOperations.CreateNode(structureMemberOfType, document);
         member.Id = newMemberGuid;
 
         document.TryFindNode<Node>(parentGuid, out var parentNode);
 
         List<IChangeInfo> changes = new() { CreateChangeInfo(member) };
-        
-        InputProperty<Painter> targetInput = parentNode.InputProperties.FirstOrDefault(x => 
+
+        InputProperty<Painter> targetInput = parentNode.InputProperties.FirstOrDefault(x =>
             x.ValueType == typeof(Painter)) as InputProperty<Painter>;
-        
+
+        var previouslyConnected = targetInput.Connection;
+
         if (member is FolderNode folder)
         {
             document.NodeGraph.AddNode(member);
@@ -54,11 +59,12 @@ internal class CreateStructureMember_Change : Change
         else
         {
             document.NodeGraph.AddNode(member);
-            List<ConnectProperty_ChangeInfo> connectPropertyChangeInfo =
+            var connectPropertyChangeInfo =
                 NodeOperations.AppendMember(targetInput, member.Output, member.Background, member.Id);
             changes.AddRange(connectPropertyChangeInfo);
         }
-
+        
+        changes.AddRange(NodeOperations.AdjustPositionsAfterAppend(member, targetInput.Node, previouslyConnected?.Node as Node, out originalPositions));
 
         ignoreInUndo = false;
 
@@ -79,7 +85,7 @@ internal class CreateStructureMember_Change : Change
     {
         var container = document.FindNodeOrThrow<Node>(parentGuid);
 
-       InputProperty<Painter> backgroundInput = container.InputProperties.FirstOrDefault(x => 
+        InputProperty<Painter> backgroundInput = container.InputProperties.FirstOrDefault(x =>
             x.ValueType == typeof(Painter)) as InputProperty<Painter>;
 
         StructureNode child = document.FindMemberOrThrow(newMemberGuid);
@@ -98,15 +104,16 @@ internal class CreateStructureMember_Change : Change
                 backgroundInput.InternalPropertyName);
             changes.Add(change);
         }
+        
+        changes.AddRange(NodeOperations.RevertPositions(originalPositions, document));
 
         return changes;
     }
 
-    private static void AppendFolder(InputProperty<Painter> backgroundInput, FolderNode folder, List<IChangeInfo> changes)
+    private static void AppendFolder(InputProperty<Painter> backgroundInput, FolderNode folder,
+        List<IChangeInfo> changes)
     {
         var appened = NodeOperations.AppendMember(backgroundInput, folder.Output, folder.Background, folder.Id);
         changes.AddRange(appened);
     }
-
-    
 }

+ 25 - 5
src/PixiEditor.ChangeableDocument/Changes/Structure/DuplicateFolder_Change.cs

@@ -1,4 +1,7 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph;
+using System.Collections.Immutable;
+using System.Collections.ObjectModel;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
@@ -13,14 +16,18 @@ internal class DuplicateFolder_Change : Change
     private Guid[] contentGuids;
     private Guid[] contentDuplicateGuids;
 
+    private Guid[]? childGuidsToUse;
+
     private ConnectionsData? connectionsData;
     private Dictionary<Guid, ConnectionsData> contentConnectionsData = new();
+    private Dictionary<Guid, VecD> originalPositions;
 
     [GenerateMakeChangeAction]
-    public DuplicateFolder_Change(Guid folderGuid, Guid newGuid)
+    public DuplicateFolder_Change(Guid folderGuid, Guid newGuid, ImmutableList<Guid>? childGuids)
     {
         this.folderGuid = folderGuid;
         duplicateGuid = newGuid;
+        childGuidsToUse = childGuids?.ToArray();
     }
 
     public override bool InitializeAndValidate(Document target)
@@ -58,11 +65,14 @@ internal class DuplicateFolder_Change : Change
 
         target.NodeGraph.AddNode(clone);
         
+        var previousConnection = targetInput.Connection;
+
         operations.Add(CreateNode_ChangeInfo.CreateFromNode(clone));
         operations.AddRange(NodeOperations.AppendMember(targetInput, clone.Output, clone.Background, clone.Id));
+        operations.AddRange(NodeOperations.AdjustPositionsAfterAppend(clone, targetInput.Node, previousConnection?.Node as Node, out originalPositions));
 
         DuplicateContent(target, clone, existingLayer, operations);
-        
+
         ignoreInUndo = false;
 
         return operations;
@@ -87,7 +97,7 @@ internal class DuplicateFolder_Change : Change
                 Node contentNode = target.FindNodeOrThrow<Node>(contentGuid);
                 changes.AddRange(NodeOperations.DetachNode(target.NodeGraph, contentNode));
                 changes.Add(new DeleteNode_ChangeInfo(contentNode.Id));
-                
+
                 target.NodeGraph.RemoveNode(contentNode);
                 contentNode.Dispose();
             }
@@ -100,6 +110,8 @@ internal class DuplicateFolder_Change : Change
                 NodeOperations.ConnectStructureNodeProperties(connectionsData, originalNode, target.NodeGraph));
         }
 
+        changes.AddRange(NodeOperations.RevertPositions(originalPositions, target));
+
         return changes;
     }
 
@@ -109,6 +121,7 @@ internal class DuplicateFolder_Change : Change
         Dictionary<Guid, Guid> nodeMap = new Dictionary<Guid, Guid>();
 
         nodeMap[existingLayer.Id] = clone.Id;
+        int counter = 0;
         List<Guid> contentGuidList = new();
 
         existingLayer.Content.Connection?.Node.TraverseBackwards(x =>
@@ -117,6 +130,13 @@ internal class DuplicateFolder_Change : Change
                 return false;
 
             Node? node = targetNode.Clone();
+            
+            if(node is not FolderNode && childGuidsToUse is not null && counter < childGuidsToUse.Length)
+            {
+                node.Id = childGuidsToUse[counter];
+                counter++;
+            }
+            
             nodeMap[x.Id] = node.Id;
             contentGuidList.Add(node.Id);
 
@@ -133,7 +153,7 @@ internal class DuplicateFolder_Change : Change
             operations.AddRange(NodeOperations.ConnectStructureNodeProperties(updatedData,
                 target.FindNodeOrThrow<Node>(targetNodeId), target.NodeGraph));
         }
-        
+
         contentDuplicateGuids = contentGuidList.ToArray();
     }
 }

+ 12 - 3
src/PixiEditor.ChangeableDocument/Changes/Structure/DuplicateLayer_Change.cs

@@ -1,4 +1,5 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
@@ -12,18 +13,19 @@ internal class DuplicateLayer_Change : Change
     private Guid duplicateGuid;
     
     private ConnectionsData? connectionsData;
+    private Dictionary<Guid, VecD> originalPositions;
 
     [GenerateMakeChangeAction]
-    public DuplicateLayer_Change(Guid layerGuid)
+    public DuplicateLayer_Change(Guid layerGuid, Guid newGuid)
     {
         this.layerGuid = layerGuid;
+        this.duplicateGuid = newGuid;
     }
 
     public override bool InitializeAndValidate(Document target)
     {
         if (!target.TryFindMember<LayerNode>(layerGuid, out LayerNode? layer))
             return false;
-        duplicateGuid = Guid.NewGuid();
         
         connectionsData = NodeOperations.CreateConnectionsData(layer);
         
@@ -42,6 +44,8 @@ internal class DuplicateLayer_Change : Change
         InputProperty<Painter?> targetInput = parent.InputProperties.FirstOrDefault(x =>
             x.ValueType == typeof(Painter) &&
             x.Connection is { Node: StructureNode }) as InputProperty<Painter?>;
+        
+        var previousConnection = targetInput.Connection;
 
         List<IChangeInfo> operations = new();
 
@@ -51,6 +55,9 @@ internal class DuplicateLayer_Change : Change
         
         operations.AddRange(NodeOperations.AppendMember(targetInput, clone.Output, clone.Background, clone.Id));
 
+        operations.AddRange(NodeOperations.AdjustPositionsAfterAppend(clone, targetInput.Node,
+            previousConnection?.Node as Node, out originalPositions));
+
         ignoreInUndo = false;
 
         return operations;
@@ -74,6 +81,8 @@ internal class DuplicateLayer_Change : Change
             changes.AddRange(NodeOperations.ConnectStructureNodeProperties(connectionsData, originalNode, target.NodeGraph));
         }
         
+        changes.AddRange(NodeOperations.RevertPositions(originalPositions, target));
+        
         return changes;
     }
 }

+ 99 - 13
src/PixiEditor.ChangeableDocument/Changes/Structure/MoveStructureMember_Change.cs

@@ -1,6 +1,8 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
 using PixiEditor.ChangeableDocument.Changes.NodeGraph;
 
@@ -14,8 +16,9 @@ internal class MoveStructureMember_Change : Change
 
     private Guid originalFolderGuid;
 
-    private ConnectionsData originalConnections; 
-    
+    private ConnectionsData originalConnections;
+    private Dictionary<Guid, VecD> originalPositions;
+
     private bool putInsideFolder;
 
 
@@ -34,20 +37,22 @@ internal class MoveStructureMember_Change : Change
         if (member is null || targetFolder is null)
             return false;
 
-        originalConnections = NodeOperations.CreateConnectionsData(member); 
-          
+        originalConnections = NodeOperations.CreateConnectionsData(member);
+
         return true;
     }
 
-    private static List<IChangeInfo> Move(Document document, Guid sourceNodeGuid, Guid targetNodeGuid, bool putInsideFolder)
+    private static List<IChangeInfo> Move(Document document, Guid sourceNodeGuid, Guid targetNodeGuid,
+        bool putInsideFolder, out Dictionary<Guid, VecD> originalPositions)
     {
         var sourceNode = document.FindMember(sourceNodeGuid);
         var targetNode = document.FindNode(targetNodeGuid);
+        originalPositions = null;
         if (sourceNode is null || targetNode is not IRenderInput backgroundInput)
             return [];
 
         List<IChangeInfo> changes = new();
-        
+
         Guid oldBackgroundId = sourceNode.Background.Node.Id;
 
         InputProperty<Painter?> inputProperty = backgroundInput.Background;
@@ -58,12 +63,43 @@ internal class MoveStructureMember_Change : Change
         }
 
         MoveStructureMember_ChangeInfo changeInfo = new(sourceNodeGuid, oldBackgroundId, targetNodeGuid);
+
+        var previouslyConnected = inputProperty.Connection;
+
+        bool isMovingBelow = false;
         
+        inputProperty.Node.TraverseForwards(x =>
+        {
+            if (x.Id == sourceNodeGuid)
+            {
+                isMovingBelow = true;
+                return false;
+            }
+            
+            return true;
+        });
+
+        if (isMovingBelow)
+        {
+            changes.AddRange(NodeOperations.AdjustPositionsBeforeAppend(sourceNode, inputProperty.Node, out originalPositions));
+        }
+
         changes.AddRange(NodeOperations.DetachStructureNode(sourceNode));
         changes.AddRange(NodeOperations.AppendMember(inputProperty, sourceNode.Output,
             sourceNode.Background,
             sourceNode.Id));
-        
+
+        if (!isMovingBelow)
+        {
+            changes.AddRange(NodeOperations.AdjustPositionsAfterAppend(sourceNode, inputProperty.Node,
+                previouslyConnected?.Node as Node, out originalPositions));
+        }
+
+        if (targetNode is FolderNode)
+        {
+            changes.AddRange(AdjustPutIntoFolderPositions(targetNode, originalPositions));
+        }
+
         changes.Add(changeInfo);
 
         return changes;
@@ -72,7 +108,7 @@ internal class MoveStructureMember_Change : Change
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
         out bool ignoreInUndo)
     {
-        var changes = Move(target, memberGuid, targetNodeGuid, putInsideFolder);
+        var changes = Move(target, memberGuid, targetNodeGuid, putInsideFolder, out originalPositions);
         ignoreInUndo = false;
         return changes;
     }
@@ -82,14 +118,64 @@ internal class MoveStructureMember_Change : Change
         StructureNode member = target.FindMember(memberGuid);
 
         List<IChangeInfo> changes = new List<IChangeInfo>();
-        
+
         MoveStructureMember_ChangeInfo changeInfo = new(memberGuid, targetNodeGuid, originalFolderGuid);
-        
+
         changes.AddRange(NodeOperations.DetachStructureNode(member));
         changes.AddRange(NodeOperations.ConnectStructureNodeProperties(originalConnections, member, target.NodeGraph));
-        
+        changes.AddRange(NodeOperations.RevertPositions(originalPositions, target));
+
         changes.Add(changeInfo);
-        
+
+        return changes;
+    }
+    
+    private static List<IChangeInfo> AdjustPutIntoFolderPositions(Node targetNode, Dictionary<Guid, VecD> originalPositions)
+    {
+        List<IChangeInfo> changes = new();
+
+        if (targetNode is FolderNode folder)
+        {
+            folder.Content.Connection.Node.TraverseBackwards(contentNode =>
+            {
+                if (contentNode is Node node)
+                {
+                    if (!originalPositions.ContainsKey(node.Id))
+                    {
+                        originalPositions[node.Id] = node.Position;
+                    }
+                    
+                    node.Position = new VecD(node.Position.X, folder.Position.Y + 250);
+                    changes.Add(new NodePosition_ChangeInfo(node.Id, node.Position));
+                }
+                
+                return true;
+            });
+            
+            folder.Background.Connection?.Node.TraverseBackwards(bgNode =>
+            {
+                if (bgNode is Node node)
+                {
+                    if (!originalPositions.ContainsKey(node.Id))
+                    {
+                        originalPositions[node.Id] = node.Position;
+                    }
+
+                    double pos = folder.Position.Y;
+
+                    if (folder.Content.Connection != null)
+                    {
+                        pos -= 250;
+                    }
+                    
+                    node.Position = new VecD(node.Position.X, pos);
+                    changes.Add(new NodePosition_ChangeInfo(node.Id, node.Position));
+                }
+                
+                return true;
+            });
+        }
+
         return changes;
     }
 }

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changes/Structure/RasterizeMember_Change.cs

@@ -46,6 +46,7 @@ internal class RasterizeMember_Change : Change
         
         ImageLayerNode imageLayer = new ImageLayerNode(target.Size, target.ProcessingColorSpace);
         imageLayer.MemberName = node.DisplayName;
+        imageLayer.Position = node.Position;
 
         target.NodeGraph.AddNode(imageLayer);
         

+ 46 - 0
src/PixiEditor.ChangeableDocument/Changes/Structure/SetLowDpiRendering_Change.cs

@@ -0,0 +1,46 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+namespace PixiEditor.ChangeableDocument.Changes.Structure;
+
+internal class SetLowDpiRendering_Change : Change
+{
+    public readonly Guid memberId;
+    public bool value;
+    
+    private bool originalValue;
+    
+    [GenerateMakeChangeAction]
+    public SetLowDpiRendering_Change(Guid memberId, bool value)
+    {
+        this.memberId = memberId;
+        this.value = value;
+    }
+    
+    public override bool InitializeAndValidate(Document target)
+    {
+        return target.TryFindNode(memberId, out RenderNode node);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        RenderNode node = target.FindNodeOrThrow<RenderNode>(memberId);
+        
+        bool toSet = !value;
+        
+        originalValue = node.AllowHighDpiRendering;
+        node.AllowHighDpiRendering = toSet;
+        
+        ignoreInUndo = originalValue == toSet;
+
+        return new None();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        RenderNode node = target.FindNodeOrThrow<RenderNode>(memberId);
+        
+        node.AllowHighDpiRendering = originalValue;
+
+        return new None();
+    }
+}

+ 73 - 0
src/PixiEditor.ChangeableDocument/Changes/Vectors/ConvertToCurve_Change.cs

@@ -0,0 +1,73 @@
+using ChunkyImageLib.Operations;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.ChangeInfos.Vectors;
+
+namespace PixiEditor.ChangeableDocument.Changes.Vectors;
+
+internal class ConvertToCurve_Change : Change
+{
+    public readonly Guid memberId;
+
+    private ShapeVectorData originalData;
+    private bool originalHighDpiRendering;
+
+    [GenerateMakeChangeAction]
+    public ConvertToCurve_Change(Guid memberId)
+    {
+        this.memberId = memberId;
+    }
+
+    public override bool InitializeAndValidate(Document target)
+    {
+        if (target.TryFindNode(memberId, out VectorLayerNode? node))
+        {
+            return node.ShapeData != null && node.ShapeData is not PathVectorData;
+        }
+
+        return false;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
+        out bool ignoreInUndo)
+    {
+        VectorLayerNode node = target.FindNodeOrThrow<VectorLayerNode>(memberId);
+        originalData = node.ShapeData;
+
+        node.ShapeData = new PathVectorData(originalData.ToPath())
+        {
+            Fill = originalData.Fill,
+            FillColor = originalData.FillColor,
+            StrokeColor = originalData.StrokeColor,
+            StrokeWidth = originalData.StrokeWidth,
+            TransformationMatrix = originalData.TransformationMatrix
+        };
+
+        originalHighDpiRendering = node.AllowHighDpiRendering;
+        node.AllowHighDpiRendering = true;
+
+        ignoreInUndo = false;
+
+        var aabb = node.ShapeData.TransformedVisualAABB;
+        var affected = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
+            (RectI)aabb, ChunkyImage.FullChunkSize));
+
+        return new VectorShape_ChangeInfo(memberId, affected);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        VectorLayerNode node = target.FindNodeOrThrow<VectorLayerNode>(memberId);
+        node.ShapeData = originalData;
+
+        node.AllowHighDpiRendering = originalHighDpiRendering;
+
+        var aabb = node.ShapeData.TransformedVisualAABB;
+        var affected = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
+            (RectI)aabb, ChunkyImage.FullChunkSize));
+
+        return new VectorShape_ChangeInfo(memberId, affected);
+    }
+}

+ 11 - 10
src/PixiEditor.ChangeableDocument/Changes/Vectors/SetShapeGeometry_UpdateableChange.cs

@@ -12,7 +12,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     public ShapeVectorData Data { get; set; }
 
     private ShapeVectorData? originalData;
-    
+
     private AffectedArea lastAffectedArea;
 
     [GenerateUpdateableChangeActions]
@@ -42,8 +42,9 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
         var node = target.FindNode<VectorLayerNode>(TargetId);
-        node.ShapeData = Data;
 
+        node.ShapeData = Data;
+        
         RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
         aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
 
@@ -51,14 +52,14 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
             (RectI)aabb, ChunkyImage.FullChunkSize));
 
         var tmp = new AffectedArea(affected);
-        
+
         if (lastAffectedArea.Chunks != null)
         {
             affected.UnionWith(lastAffectedArea);
         }
-        
+
         lastAffectedArea = tmp;
-        
+
         return new VectorShape_ChangeInfo(node.Id, affected);
     }
 
@@ -68,7 +69,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
         ignoreInUndo = false;
         var node = target.FindNode<VectorLayerNode>(TargetId);
         node.ShapeData = Data;
-        
+
         RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
         aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
 
@@ -84,12 +85,12 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
         node.ShapeData = originalData;
 
         AffectedArea affected = new AffectedArea();
-        
+
         if (node.ShapeData != null)
-        { 
+        {
             RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
             aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
-         
+
             affected = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
                 (RectI)aabb, ChunkyImage.FullChunkSize));
         }
@@ -101,7 +102,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     {
         if (other is SetShapeGeometry_UpdateableChange change)
         {
-            return change.TargetId == TargetId;
+            return change.TargetId == TargetId && change.Data is not TextVectorData; // text should not be merged into one change
         }
 
         return false;

+ 1 - 0
src/PixiEditor.Extensions/UI/Overlays/OverlayPointerArgs.cs

@@ -11,4 +11,5 @@ public class OverlayPointerArgs
     public MouseButton InitialPressMouseButton { get; set; }
     public IOverlayPointer Pointer { get; set; }
     public bool Handled { get; set; }
+    public int ClickCount { get; set; }
 }

+ 4 - 1
src/PixiEditor.SVG/Elements/SvgGroup.cs

@@ -5,19 +5,22 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgGroup() : SvgElement("g"), ITransformable, IFillable, IStrokable, IElementContainer
+public class SvgGroup() : SvgElement("g"), ITransformable, IFillable, IStrokable, IOpacity, IElementContainer
 {
     public List<SvgElement> Children { get; } = new();
     public SvgProperty<SvgTransformUnit> Transform { get; } = new("transform");
     public SvgProperty<SvgColorUnit> Fill { get; } = new("fill");
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; } = new("fill-opacity");
     public SvgProperty<SvgColorUnit> Stroke { get; } = new("stroke");
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; } = new("stroke-width");
     public SvgProperty<SvgEnumUnit<SvgStrokeLineCap>> StrokeLineCap { get; } = new("stroke-linecap");
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; } = new("stroke-linejoin");
+    public SvgProperty<SvgNumericUnit> Opacity { get; } = new("opacity");
 
     public override void ParseData(XmlReader reader)
     {
         List<SvgProperty> properties = new List<SvgProperty>() { Transform, Fill, Stroke, StrokeWidth, StrokeLineCap, StrokeLineJoin };
         ParseAttributes(properties, reader);
     }
+
 }

+ 6 - 1
src/PixiEditor.SVG/Elements/SvgPrimitive.cs

@@ -5,10 +5,11 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG.Elements;
 
-public abstract class SvgPrimitive(string tagName) : SvgElement(tagName), ITransformable, IFillable, IStrokable
+public abstract class SvgPrimitive(string tagName) : SvgElement(tagName), ITransformable, IFillable, IStrokable, IOpacity
 {
     public SvgProperty<SvgTransformUnit> Transform { get; } = new("transform");
     public SvgProperty<SvgColorUnit> Fill { get; } = new("fill");
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; } = new("fill-opacity");
     public SvgProperty<SvgColorUnit> Stroke { get; } = new("stroke");
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; } = new("stroke-width");
     
@@ -16,16 +17,20 @@ public abstract class SvgPrimitive(string tagName) : SvgElement(tagName), ITrans
     
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; } = new("stroke-linejoin");
 
+    public SvgProperty<SvgNumericUnit> Opacity { get; } = new("opacity");
+
     public override void ParseData(XmlReader reader)
     {
         List<SvgProperty> properties = GetProperties().ToList();
         
         properties.Add(Transform);
         properties.Add(Fill);
+        properties.Add(FillOpacity);
         properties.Add(Stroke);
         properties.Add(StrokeWidth);
         properties.Add(StrokeLineCap);
         properties.Add(StrokeLineJoin);
+        properties.Add(Opacity);
 
         do
         {

+ 51 - 0
src/PixiEditor.SVG/Elements/SvgText.cs

@@ -0,0 +1,51 @@
+using System.Xml;
+using PixiEditor.SVG.Enums;
+using PixiEditor.SVG.Units;
+
+namespace PixiEditor.SVG.Elements;
+
+public class SvgText() : SvgPrimitive("text")
+{
+    public SvgProperty<SvgStringUnit> Text { get; } = new("");
+    public SvgProperty<SvgNumericUnit> X { get; } = new("x");
+    public SvgProperty<SvgNumericUnit> Y { get; } = new("y");
+    public SvgProperty<SvgNumericUnit> FontSize { get; } = new("font-size");
+    public SvgProperty<SvgStringUnit> FontFamily { get; } = new("font-family");
+    public SvgProperty<SvgEnumUnit<SvgFontWeight>> FontWeight { get; } = new("font-weight");
+    public SvgProperty<SvgEnumUnit<SvgFontStyle>> FontStyle { get; } = new("font-style");
+
+    public override void ParseData(XmlReader reader)
+    {
+        base.ParseData(reader);
+        Text.Unit = new SvgStringUnit(ParseContent(reader));
+    }
+
+    protected override IEnumerable<SvgProperty> GetProperties()
+    {
+        yield return X;
+        yield return Y;
+        yield return FontSize;
+        yield return FontFamily;
+        yield return FontWeight;
+        yield return FontStyle;
+    }
+
+    private string ParseContent(XmlReader reader)
+    {
+        string content = string.Empty;
+        if (reader.NodeType == XmlNodeType.None) return content;
+        while (reader.Read())
+        {
+            if (reader.NodeType == XmlNodeType.Text || reader.NodeType == XmlNodeType.CDATA)
+            {
+                content = reader.Value;
+            }
+            else if (reader is { NodeType: XmlNodeType.EndElement, Name: "text" })
+            {
+                break;
+            }
+        }
+
+        return content;
+    }
+}

+ 8 - 0
src/PixiEditor.SVG/Enums/SvgFontStyle.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.SVG.Enums;
+
+public enum SvgFontStyle
+{
+    Normal,
+    Italic,
+    Oblique,
+}

+ 9 - 0
src/PixiEditor.SVG/Enums/SvgFontWeight.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.SVG.Enums;
+
+public enum SvgFontWeight
+{
+    Lighter = 100,
+    Normal = 400,
+    Bold = 700,
+    Bolder = 900,
+}

+ 1 - 0
src/PixiEditor.SVG/Features/IFillable.cs

@@ -6,4 +6,5 @@ namespace PixiEditor.SVG.Features;
 public interface IFillable
 {
     public SvgProperty<SvgColorUnit> Fill { get; }
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; }
 }

+ 8 - 0
src/PixiEditor.SVG/Features/IOpacity.cs

@@ -0,0 +1,8 @@
+using PixiEditor.SVG.Units;
+
+namespace PixiEditor.SVG.Features;
+
+public interface IOpacity
+{
+    public SvgProperty<SvgNumericUnit> Opacity { get; }
+}

+ 154 - 32
src/PixiEditor.SVG/StyleContext.cs

@@ -1,4 +1,6 @@
-using PixiEditor.SVG.Enums;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Numerics;
+using PixiEditor.SVG.Enums;
 using PixiEditor.SVG.Features;
 using PixiEditor.SVG.Units;
 
@@ -9,69 +11,102 @@ public struct StyleContext
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; }
     public SvgProperty<SvgColorUnit> Stroke { get; }
     public SvgProperty<SvgColorUnit> Fill { get; }
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; }
     public SvgProperty<SvgTransformUnit> Transform { get; }
-    
     public SvgProperty<SvgEnumUnit<SvgStrokeLineCap>> StrokeLineCap { get; }
-    
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; }
+    public SvgProperty<SvgNumericUnit> Opacity { get; }
+    public SvgProperty<SvgStyleUnit> InlineStyle { get; set; }
+    public VecD ViewboxOrigin { get; set; }
 
     public StyleContext()
     {
         StrokeWidth = new("stroke-width");
         Stroke = new("stroke");
         Fill = new("fill");
+        FillOpacity = new("fill-opacity");
+        Fill.Unit = new SvgColorUnit?(new SvgColorUnit("black"));
         Transform = new("transform");
         StrokeLineCap = new("stroke-linecap");
         StrokeLineJoin = new("stroke-linejoin");
+        Opacity = new("opacity");
+        InlineStyle = new("style");
     }
-    
+
     public StyleContext(SvgDocument document)
     {
-        StrokeWidth = document.StrokeWidth;
-        Stroke = document.Stroke;
-        Fill = document.Fill;
-        Transform = document.Transform;
-        StrokeLineCap = document.StrokeLineCap;
-        StrokeLineJoin = document.StrokeLineJoin;
+        StrokeWidth = FallbackToCssStyle(document.StrokeWidth, document.Style);
+        Stroke = FallbackToCssStyle(document.Stroke, document.Style);
+        Fill = FallbackToCssStyle(document.Fill, document.Style, new SvgColorUnit("black"));
+        FillOpacity = FallbackToCssStyle(document.FillOpacity, document.Style);
+        Transform = FallbackToCssStyle(document.Transform, document.Style, new SvgTransformUnit(Matrix3X3.Identity));
+        StrokeLineCap = FallbackToCssStyle(document.StrokeLineCap, document.Style);
+        StrokeLineJoin = FallbackToCssStyle(document.StrokeLineJoin, document.Style);
+        Opacity = FallbackToCssStyle(document.Opacity, document.Style);
+        ViewboxOrigin = new VecD(
+            document.ViewBox.Unit.HasValue ? -document.ViewBox.Unit.Value.Value.X : 0,
+            document.ViewBox.Unit.HasValue ? -document.ViewBox.Unit.Value.Value.Y : 0);
+        InlineStyle = document.Style;
     }
 
     public StyleContext WithElement(SvgElement element)
     {
         StyleContext styleContext = Copy();
 
-        if (element is ITransformable { Transform.Unit: not null } transformableElement)
+        styleContext.InlineStyle = MergeInlineStyle(element.Style, InlineStyle);
+
+        if (element is ITransformable transformableElement)
         {
-            styleContext.Transform.Unit = transformableElement.Transform.Unit;
+            if (styleContext.Transform.Unit == null)
+            {
+                styleContext.Transform.Unit =
+                    FallbackToCssStyle(transformableElement.Transform, styleContext.Transform, styleContext.InlineStyle)
+                        .Unit;
+            }
+            else
+            {
+                styleContext.Transform.Unit = new SvgTransformUnit(
+                    styleContext.Transform.Unit.Value.MatrixValue.Concat(
+                        FallbackToCssStyle(transformableElement.Transform, styleContext.InlineStyle).Unit
+                            ?.MatrixValue ??
+                        Matrix3X3.Identity));
+            }
         }
 
-        if (element is IFillable { Fill.Unit: not null } fillableElement)
+        if (element is IFillable fillableElement)
         {
-            styleContext.Fill.Unit = fillableElement.Fill.Unit;
+            styleContext.Fill.Unit = FallbackToCssStyle(fillableElement.Fill, styleContext.Fill,
+                styleContext.InlineStyle, new SvgColorUnit("black")).Unit;
+            styleContext.FillOpacity.Unit =
+                FallbackToCssStyle(fillableElement.FillOpacity, styleContext.FillOpacity, styleContext.InlineStyle)
+                    .Unit;
         }
 
         if (element is IStrokable strokableElement)
         {
-            if (strokableElement.Stroke.Unit != null)
-            {
-                styleContext.Stroke.Unit = strokableElement.Stroke.Unit;
-            }
+            styleContext.Stroke.Unit =
+                FallbackToCssStyle(strokableElement.Stroke, styleContext.Stroke, styleContext.InlineStyle).Unit;
 
-            if (strokableElement.StrokeWidth.Unit != null)
-            {
-                styleContext.StrokeWidth.Unit = strokableElement.StrokeWidth.Unit;
-            }
-            
-            if (strokableElement.StrokeLineCap.Unit != null)
-            {
-                styleContext.StrokeLineCap.Unit = strokableElement.StrokeLineCap.Unit;
-            }
-            
-            if (strokableElement.StrokeLineJoin.Unit != null)
-            {
-                styleContext.StrokeLineJoin.Unit = strokableElement.StrokeLineJoin.Unit;
-            }
+            styleContext.StrokeWidth.Unit =
+                FallbackToCssStyle(strokableElement.StrokeWidth, styleContext.StrokeWidth, styleContext.InlineStyle)
+                    .Unit;
+
+            styleContext.StrokeLineCap.Unit =
+                FallbackToCssStyle(strokableElement.StrokeLineCap, styleContext.StrokeLineCap, styleContext.InlineStyle)
+                    .Unit;
+
+            styleContext.StrokeLineJoin.Unit =
+                FallbackToCssStyle(strokableElement.StrokeLineJoin, styleContext.StrokeLineJoin,
+                    styleContext.InlineStyle).Unit;
         }
 
+        if (element is IOpacity opacityElement)
+        {
+            styleContext.Opacity.Unit =
+                FallbackToCssStyle(opacityElement.Opacity, styleContext.Opacity, styleContext.InlineStyle).Unit;
+        }
+
+
         return styleContext;
     }
 
@@ -93,11 +128,98 @@ public struct StyleContext
             styleContext.Fill.Unit = Fill.Unit;
         }
 
+        if (FillOpacity.Unit != null)
+        {
+            styleContext.FillOpacity.Unit = FillOpacity.Unit;
+        }
+
         if (Transform.Unit != null)
         {
             styleContext.Transform.Unit = Transform.Unit;
         }
 
+        if (StrokeLineCap.Unit != null)
+        {
+            styleContext.StrokeLineCap.Unit = StrokeLineCap.Unit;
+        }
+
+        if (StrokeLineJoin.Unit != null)
+        {
+            styleContext.StrokeLineJoin.Unit = StrokeLineJoin.Unit;
+        }
+
+        if (Opacity.Unit != null)
+        {
+            styleContext.Opacity.Unit = Opacity.Unit;
+        }
+
+        styleContext.ViewboxOrigin = ViewboxOrigin;
+
+        if (InlineStyle.Unit != null)
+        {
+            styleContext.InlineStyle.Unit = InlineStyle.Unit;
+        }
+
         return styleContext;
     }
+
+
+    private SvgProperty<TUnit>? FallbackToCssStyle<TUnit>(
+        SvgProperty<TUnit> property,
+        SvgProperty<SvgStyleUnit> inlineStyle, TUnit? fallback = null) where TUnit : struct, ISvgUnit
+    {
+        if (property.Unit != null)
+        {
+            return property;
+        }
+
+        SvgStyleUnit? style = inlineStyle.Unit;
+        return style?.TryGetStyleFor<SvgProperty<TUnit>, TUnit>(property.SvgName)
+               ?? (fallback.HasValue
+                   ? new SvgProperty<TUnit>(property.SvgName) { Unit = fallback.Value }
+                   : new SvgProperty<TUnit>(property.SvgName));
+    }
+
+    private SvgProperty<TUnit>? FallbackToCssStyle<TUnit>(
+        SvgProperty<TUnit> property,
+        SvgProperty<TUnit> parentStyleProperty,
+        SvgProperty<SvgStyleUnit> inlineStyle, TUnit? fallback = null) where TUnit : struct, ISvgUnit
+    {
+        if (property.Unit != null)
+        {
+            return property;
+        }
+
+        SvgStyleUnit? style = inlineStyle.Unit;
+        var styleProp = style?.TryGetStyleFor<SvgProperty<TUnit>, TUnit>(property.SvgName);
+        if (styleProp != null) return styleProp;
+        if(parentStyleProperty.Unit != null)
+        {
+            return parentStyleProperty;
+        }
+
+        return (fallback.HasValue
+            ? new SvgProperty<TUnit>(property.SvgName) { Unit = fallback.Value }
+            : new SvgProperty<TUnit>(property.SvgName));
+    }
+
+    private SvgProperty<SvgStyleUnit> MergeInlineStyle(SvgProperty<SvgStyleUnit> elementStyle,
+        SvgProperty<SvgStyleUnit> parentStyle)
+    {
+        SvgStyleUnit? elementStyleUnit = elementStyle.Unit;
+        SvgStyleUnit? parentStyleUnit = parentStyle.Unit;
+
+        if (elementStyleUnit == null)
+        {
+            return parentStyle;
+        }
+
+        if (parentStyleUnit == null)
+        {
+            return elementStyle;
+        }
+
+        SvgStyleUnit style = parentStyleUnit.Value.MergeWith(elementStyleUnit.Value);
+        return new SvgProperty<SvgStyleUnit>("style") { Unit = style };
+    }
 }

+ 16 - 13
src/PixiEditor.SVG/SvgDocument.cs

@@ -7,27 +7,28 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG;
 
-public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFillable, IStrokable
+public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFillable, IStrokable, IOpacity
 {
     public string RootNamespace { get; set; } = "http://www.w3.org/2000/svg";
     public string Version { get; set; } = "1.1";
-    
+
     public SvgProperty<SvgRectUnit> ViewBox { get; } = new("viewBox");
     public SvgProperty<SvgColorUnit> Fill { get; } = new("fill");
     public SvgProperty<SvgColorUnit> Stroke { get; } = new("stroke");
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; } = new("stroke-width");
-    
+
     public SvgProperty<SvgEnumUnit<SvgStrokeLineCap>> StrokeLineCap { get; } = new("stroke-linecap");
-    
+
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; } = new("stroke-linejoin");
     public SvgProperty<SvgTransformUnit> Transform { get; } = new("transform");
+    public SvgProperty<SvgNumericUnit> Opacity { get; } = new("opacity");
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; } = new("fill-opacity");
     public List<SvgElement> Children { get; } = new();
 
     public SvgDocument() : base("svg")
     {
-        
     }
-    
+
     public SvgDocument(RectD viewBox) : base("svg")
     {
         ViewBox.Unit = new SvgRectUnit(viewBox);
@@ -38,14 +39,16 @@ public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFilla
         List<SvgProperty> properties = new()
         {
             Fill,
+            FillOpacity,
             Stroke,
             StrokeWidth,
             Transform,
             ViewBox,
             StrokeLineCap,
-            StrokeLineJoin
+            StrokeLineJoin,
+            Opacity
         };
-        
+
         ParseAttributes(properties, reader);
     }
 
@@ -102,11 +105,11 @@ public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFilla
 
     private void AppendProperties(XElement? root)
     {
-        if(ViewBox.Unit != null)
+        if (ViewBox.Unit != null)
         {
             root.Add(new XAttribute("viewBox", ViewBox.Unit.Value.ToXml()));
         }
-        
+
         if (Fill.Unit != null)
         {
             root.Add(new XAttribute("fill", Fill.Unit.Value.ToXml()));
@@ -121,17 +124,17 @@ public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFilla
         {
             root.Add(new XAttribute("stroke-width", StrokeWidth.Unit.Value.ToXml()));
         }
-        
+
         if (Transform.Unit != null)
         {
             root.Add(new XAttribute("transform", Transform.Unit.Value.ToXml()));
         }
-        
+
         if (StrokeLineCap.Unit != null)
         {
             root.Add(new XAttribute("stroke-linecap", StrokeLineCap.Unit.Value.ToXml()));
         }
-        
+
         if (StrokeLineJoin.Unit != null)
         {
             root.Add(new XAttribute("stroke-linejoin", StrokeLineJoin.Unit.Value.ToXml()));

+ 24 - 23
src/PixiEditor.SVG/SvgElement.cs

@@ -13,6 +13,7 @@ public class SvgElement(string tagName)
     public Dictionary<string, string> RequiredNamespaces { get; } = new();
     public string TagName { get; } = tagName;
 
+    public SvgProperty<SvgStyleUnit> Style { get; } = new("style");
 
     public XElement ToXml(XNamespace nameSpace)
     {
@@ -25,14 +26,21 @@ public class SvgElement(string tagName)
                 SvgProperty prop = (SvgProperty)property.GetValue(this);
                 if (prop?.Unit != null)
                 {
-                    if (!string.IsNullOrEmpty(prop.NamespaceName))
+                    if (string.IsNullOrEmpty(prop.SvgName))
                     {
-                        XName name = XNamespace.Get(RequiredNamespaces[prop.NamespaceName]) + prop.SvgName;
-                        element.Add(new XAttribute(name, prop.Unit.ToXml()));
+                        element.Value = prop.Unit.ToXml();
                     }
                     else
                     {
-                        element.Add(new XAttribute(prop.SvgName, prop.Unit.ToXml()));
+                        if (!string.IsNullOrEmpty(prop.NamespaceName))
+                        {
+                            XName name = XNamespace.Get(RequiredNamespaces[prop.NamespaceName]) + prop.SvgName;
+                            element.Add(new XAttribute(name, prop.Unit.ToXml()));
+                        }
+                        else
+                        {
+                            element.Add(new XAttribute(prop.SvgName, prop.Unit.ToXml()));
+                        }
                     }
                 }
             }
@@ -57,6 +65,16 @@ public class SvgElement(string tagName)
 
     protected void ParseAttributes(List<SvgProperty> properties, XmlReader reader)
     {
+        if (!properties.Contains(Id))
+        {
+            properties.Insert(0, Id);
+        }
+
+        if (!properties.Contains(Style))
+        {
+            properties.Insert(0, Style);
+        }
+
         do
         {
             SvgProperty matchingProperty = properties.FirstOrDefault(x =>
@@ -76,31 +94,14 @@ public class SvgElement(string tagName)
         }
         else
         {
-            property.Unit ??= CreateDefaultUnit(property);
+            property.Unit ??= property.CreateDefaultUnit();
             property.Unit.ValuesFromXml(reader.Value);
         }
     }
 
     private void ParseListProperty(SvgList list, XmlReader reader)
     {
-        list.Unit ??= CreateDefaultUnit(list);
+        list.Unit ??= list.CreateDefaultUnit();
         list.Unit.ValuesFromXml(reader.Value);
     }
-
-    private ISvgUnit CreateDefaultUnit(SvgProperty property)
-    {
-        var genericType = property.GetType().GetGenericArguments();
-        if (genericType.Length == 0)
-        {
-            throw new InvalidOperationException("Property does not have a generic type");
-        }
-
-        ISvgUnit unit = Activator.CreateInstance(genericType[0]) as ISvgUnit;
-        if (unit == null)
-        {
-            throw new InvalidOperationException("Could not create unit");
-        }
-
-        return unit;
-    }
 }

+ 14 - 12
src/PixiEditor.SVG/SvgParser.cs

@@ -1,4 +1,5 @@
-using System.Xml;
+using System.Globalization;
+using System.Xml;
 using System.Xml.Linq;
 using Drawie.Numerics;
 using PixiEditor.SVG.Elements;
@@ -19,7 +20,8 @@ public class SvgParser
         { "g", typeof(SvgGroup) },
         { "mask", typeof(SvgMask) },
         { "image", typeof(SvgImage) },
-        { "svg", typeof(SvgDocument) }
+        { "svg", typeof(SvgDocument) },
+        { "text", typeof(SvgText) }
     };
 
     public string Source { get; set; }
@@ -35,7 +37,7 @@ public class SvgParser
         using var reader = document.CreateReader();
 
         XmlNodeType node = reader.MoveToContent();
-        if (node != XmlNodeType.Element || reader.Name != "svg")
+        if (node != XmlNodeType.Element || reader.LocalName != "svg")
         {
             return null;
         }
@@ -92,7 +94,7 @@ public class SvgParser
 
     private SvgElement? ParseElement(XmlReader reader)
     {
-        if (wellKnownElements.TryGetValue(reader.Name, out Type elementType))
+        if (wellKnownElements.TryGetValue(reader.LocalName, out Type elementType))
         {
             SvgElement element = (SvgElement)Activator.CreateInstance(elementType);
             if (reader.MoveToFirstAttribute())
@@ -129,16 +131,16 @@ public class SvgParser
             string[] parts = viewBox.Split(' ').Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
             if (parts.Length == 4)
             {
-                finalX = double.Parse(parts[0]);
-                finalY = double.Parse(parts[1]);
-                finalWidth = double.Parse(parts[2]);
-                finalHeight = double.Parse(parts[3]);
+                finalX = double.Parse(parts[0], CultureInfo.InvariantCulture);
+                finalY = double.Parse(parts[1], CultureInfo.InvariantCulture);
+                finalWidth = double.Parse(parts[2], CultureInfo.InvariantCulture);
+                finalHeight = double.Parse(parts[3], CultureInfo.InvariantCulture);
             }
         }
 
         if (x != null)
         {
-            if (double.TryParse(x, out double xValue))
+            if (double.TryParse(x, CultureInfo.InvariantCulture, out double xValue))
             {
                 finalX = xValue;
             }
@@ -146,7 +148,7 @@ public class SvgParser
 
         if (y != null)
         {
-            if (double.TryParse(y, out double yValue))
+            if (double.TryParse(y, CultureInfo.InvariantCulture, out double yValue))
             {
                 finalY = yValue;
             }
@@ -154,7 +156,7 @@ public class SvgParser
 
         if (width != null)
         {
-            if (double.TryParse(width, out double widthValue))
+            if (double.TryParse(width, CultureInfo.InvariantCulture, out double widthValue))
             {
                 finalWidth = widthValue;
             }
@@ -162,7 +164,7 @@ public class SvgParser
 
         if (height != null)
         {
-            if (double.TryParse(height, out double heightValue))
+            if (double.TryParse(height, CultureInfo.InvariantCulture, out double heightValue))
             {
                 finalHeight = heightValue;
             }

+ 20 - 3
src/PixiEditor.SVG/SvgProperty.cs

@@ -9,7 +9,7 @@ public abstract class SvgProperty
     {
         SvgName = svgName;
     }
-    
+
     protected SvgProperty(string svgName, string? namespaceName) : this(svgName)
     {
         NamespaceName = namespaceName;
@@ -18,6 +18,23 @@ public abstract class SvgProperty
     public string? NamespaceName { get; set; }
     public string SvgName { get; set; }
     public ISvgUnit? Unit { get; set; }
+
+    public ISvgUnit? CreateDefaultUnit()
+    {
+        var genericType = this.GetType().GetGenericArguments();
+        if (genericType.Length == 0)
+        {
+            return null;
+        }
+
+        ISvgUnit unit = Activator.CreateInstance(genericType[0]) as ISvgUnit;
+        if (unit == null)
+        {
+            throw new InvalidOperationException("Could not create unit");
+        }
+
+        return unit;
+    }
 }
 
 public class SvgProperty<T> : SvgProperty where T : struct, ISvgUnit
@@ -27,11 +44,11 @@ public class SvgProperty<T> : SvgProperty where T : struct, ISvgUnit
         get => (T?)base.Unit;
         set => base.Unit = value;
     }
-    
+
     public SvgProperty(string svgName) : base(svgName)
     {
     }
-    
+
     public SvgProperty(string svgName, string? namespaceName) : base(svgName, namespaceName)
     {
     }

+ 35 - 15
src/PixiEditor.SVG/Units/SvgNumericUnit.cs

@@ -6,32 +6,51 @@ public struct SvgNumericUnit(double value, string postFix) : ISvgUnit
 {
     public string PostFix { get; set; } = postFix;
     public double Value { get; set; } = value;
+    public double? PixelsValue => ConvertTo(SvgNumericUnits.Px);
+
+    public double? ConvertTo(SvgNumericUnits other)
+    {
+        SvgNumericUnits? numericUnit = SvgNumericUnitsExtensions.TryParseUnit(PostFix);
+
+        if (numericUnit == null || !numericUnit.Value.IsSizeUnit() || !numericUnit.Value.IsAbsoluteUnit())
+        {
+            return null;
+        }
+
+        double? pixelsValue = SvgNumericConverter.ToPixels(Value, numericUnit.Value);
+        if (pixelsValue == null)
+        {
+            return null;
+        }
+
+        return SvgNumericConverter.FromPixels(pixelsValue.Value, other);
+    }
 
     public static SvgNumericUnit FromUserUnits(double value)
     {
         return new SvgNumericUnit(value, string.Empty);
     }
-    
+
     public static SvgNumericUnit FromPixels(double value)
     {
         return new SvgNumericUnit(value, "px");
     }
-    
+
     public static SvgNumericUnit FromInches(double value)
     {
         return new SvgNumericUnit(value, "in");
     }
-    
+
     public static SvgNumericUnit FromCentimeters(double value)
     {
         return new SvgNumericUnit(value, "cm");
     }
-    
+
     public static SvgNumericUnit FromMillimeters(double value)
     {
         return new SvgNumericUnit(value, "mm");
     }
-    
+
     public static SvgNumericUnit FromPercent(double value)
     {
         return new SvgNumericUnit(value, "%");
@@ -46,7 +65,7 @@ public struct SvgNumericUnit(double value, string postFix) : ISvgUnit
     public void ValuesFromXml(string readerValue)
     {
         string? extractedPostFix = ExtractPostFix(readerValue);
-        
+
         if (extractedPostFix == null)
         {
             if (double.TryParse(readerValue, NumberStyles.Any, CultureInfo.InvariantCulture, out double result))
@@ -65,7 +84,7 @@ public struct SvgNumericUnit(double value, string postFix) : ISvgUnit
             }
         }
     }
-    
+
     private string? ExtractPostFix(string readerValue)
     {
         if (readerValue.Length == 0)
@@ -74,21 +93,22 @@ public struct SvgNumericUnit(double value, string postFix) : ISvgUnit
         }
 
         int postFixStartIndex = readerValue.Length;
-        
+
+        if (char.IsDigit(readerValue[^1]))
+        {
+            return null;
+        }
+
         for (int i = readerValue.Length - 1; i >= 0; i--)
         {
-            if (!char.IsDigit(readerValue[i]))
+            if (char.IsDigit(readerValue[i]))
             {
                 postFixStartIndex = i + 1;
                 break;
             }
         }
-        
-        if (postFixStartIndex == readerValue.Length)
-        {
-            return null;
-        }
-        
+
+
         return readerValue.Substring(postFixStartIndex);
     }
 }

+ 142 - 0
src/PixiEditor.SVG/Units/SvgNumericUnits.cs

@@ -0,0 +1,142 @@
+namespace PixiEditor.SVG.Units;
+
+public enum SvgNumericUnits
+{
+    Px,
+    In,
+    Cm,
+    Mm,
+    Pt,
+    Pc,
+    Em,
+    Ex,
+    Ch,
+    Rem,
+    Vw,
+    Vh,
+    Vmin,
+    Vmax,
+    Percent,
+    Deg,
+    Rad,
+    Grad,
+    Turn,
+    S,
+    Ms,
+    Min,
+    H,
+    Mmss,
+    Hhmmss,
+}
+
+public static class SvgNumericConverter
+{
+    public static double? ToPixels(double value, SvgNumericUnits unit)
+    {
+        if (!unit.IsAbsoluteUnit() && !unit.IsSizeUnit()) return null;
+
+        return unit switch
+        {
+            SvgNumericUnits.Px => value,
+            SvgNumericUnits.In => value * 96,
+            SvgNumericUnits.Cm => value * 37.795,
+            SvgNumericUnits.Mm => value * 3.7795,
+            SvgNumericUnits.Pt => value * 1.3333,
+            SvgNumericUnits.Pc => value * 16,
+            _ => null,
+        };
+    }
+
+    public static double? FromPixels(double pixelsValue, SvgNumericUnits other)
+    {
+        if (!other.IsAbsoluteUnit() && !other.IsSizeUnit()) return null;
+
+        return other switch
+        {
+            SvgNumericUnits.Px => pixelsValue,
+            SvgNumericUnits.In => pixelsValue / 96,
+            SvgNumericUnits.Cm => pixelsValue / 37.795,
+            SvgNumericUnits.Mm => pixelsValue / 3.7795,
+            SvgNumericUnits.Pt => pixelsValue / 1.3333,
+            SvgNumericUnits.Pc => pixelsValue / 16,
+            _ => null,
+        };
+    }
+}
+
+public static class SvgNumericUnitsExtensions
+{
+    public static bool IsSizeUnit(this SvgNumericUnits unit)
+    {
+        return unit switch
+        {
+            SvgNumericUnits.Px => true,
+            SvgNumericUnits.In => true,
+            SvgNumericUnits.Cm => true,
+            SvgNumericUnits.Mm => true,
+            SvgNumericUnits.Pt => true,
+            SvgNumericUnits.Pc => true,
+            SvgNumericUnits.Em => true,
+            SvgNumericUnits.Ex => true,
+            SvgNumericUnits.Ch => true,
+            SvgNumericUnits.Rem => true,
+            SvgNumericUnits.Vw => true,
+            SvgNumericUnits.Vh => true,
+            SvgNumericUnits.Vmin => true,
+            SvgNumericUnits.Vmax => true,
+            SvgNumericUnits.Percent => true,
+            _ => false,
+        };
+    }
+
+    public static bool IsAbsoluteUnit(this SvgNumericUnits unit)
+    {
+        return unit switch
+        {
+            SvgNumericUnits.Px => true,
+            SvgNumericUnits.In => true,
+            SvgNumericUnits.Cm => true,
+            SvgNumericUnits.Mm => true,
+            SvgNumericUnits.Pt => true,
+            SvgNumericUnits.Pc => true,
+            SvgNumericUnits.Rad => true,
+            SvgNumericUnits.Deg => true,
+            SvgNumericUnits.Grad => true,
+            _ => false,
+        };
+    }
+
+    public static SvgNumericUnits? TryParseUnit(string postFix)
+    {
+        if (string.IsNullOrWhiteSpace(postFix)) return SvgNumericUnits.Px;
+        return postFix.ToLower().Trim() switch
+        {
+            "px" => SvgNumericUnits.Px,
+            "in" => SvgNumericUnits.In,
+            "cm" => SvgNumericUnits.Cm,
+            "mm" => SvgNumericUnits.Mm,
+            "pt" => SvgNumericUnits.Pt,
+            "pc" => SvgNumericUnits.Pc,
+            "em" => SvgNumericUnits.Em,
+            "ex" => SvgNumericUnits.Ex,
+            "ch" => SvgNumericUnits.Ch,
+            "rem" => SvgNumericUnits.Rem,
+            "vw" => SvgNumericUnits.Vw,
+            "vh" => SvgNumericUnits.Vh,
+            "vmin" => SvgNumericUnits.Vmin,
+            "vmax" => SvgNumericUnits.Vmax,
+            "%" => SvgNumericUnits.Percent,
+            "deg" => SvgNumericUnits.Deg,
+            "rad" => SvgNumericUnits.Rad,
+            "grad" => SvgNumericUnits.Grad,
+            "turn" => SvgNumericUnits.Turn,
+            "s" => SvgNumericUnits.S,
+            "ms" => SvgNumericUnits.Ms,
+            "min" => SvgNumericUnits.Min,
+            "h" => SvgNumericUnits.H,
+            "mm:ss" => SvgNumericUnits.Mmss,
+            "hh:mm:ss" => SvgNumericUnits.Hhmmss,
+            _ => null,
+        };
+    }
+}

+ 73 - 0
src/PixiEditor.SVG/Units/SvgStyleUnit.cs

@@ -0,0 +1,73 @@
+namespace PixiEditor.SVG.Units;
+
+public struct SvgStyleUnit : ISvgUnit
+{
+    private Dictionary<string, string> inlineDefinedProperties;
+    private string value;
+
+    public SvgStyleUnit(string inlineStyle)
+    {
+        Value = inlineStyle;
+    }
+
+    public string Value
+    {
+        get => value;
+        set
+        {
+            this.value = value;
+            inlineDefinedProperties = new Dictionary<string, string>();
+
+            if (string.IsNullOrEmpty(value))
+            {
+                return;
+            }
+
+            string[] properties = value.Split(';');
+            foreach (string property in properties)
+            {
+                string[] keyValue = property.Split(':');
+                if (keyValue.Length == 2)
+                {
+                    inlineDefinedProperties.Add(keyValue[0].Trim(), keyValue[1].Trim());
+                }
+            }
+        }
+    }
+
+    public string ToXml()
+    {
+        return Value;
+    }
+
+    public void ValuesFromXml(string readerValue)
+    {
+        Value = readerValue;
+    }
+
+    public TProp TryGetStyleFor<TProp, TUnit>(string property) where TProp : SvgProperty<TUnit> where TUnit : struct, ISvgUnit
+    {
+        if (inlineDefinedProperties.TryGetValue(property, out var definedProperty))
+        {
+            TProp prop = (TProp)Activator.CreateInstance(typeof(TProp), property);
+            var unit = (TUnit)prop.CreateDefaultUnit();
+            unit.ValuesFromXml(definedProperty);
+            prop.Unit = unit;
+
+            return prop;
+        }
+
+        return null;
+    }
+
+    public SvgStyleUnit MergeWith(SvgStyleUnit elementStyleUnit)
+    {
+        Dictionary<string, string> props = new(inlineDefinedProperties);
+        foreach (var inlineDefined in elementStyleUnit.inlineDefinedProperties)
+        {
+            props[inlineDefined.Key] = inlineDefined.Value;
+        }
+
+        return new SvgStyleUnit(string.Join(";", props.Select(x => $"{x.Key}:{x.Value}")));
+    }
+}

+ 98 - 4
src/PixiEditor.SVG/Units/SvgTransformUnit.cs

@@ -11,12 +11,12 @@ public struct SvgTransformUnit : ISvgUnit
     }
 
     public Matrix3X3 MatrixValue { get; set; } = Matrix3X3.Identity;
-    
+
     public SvgTransformUnit(Matrix3X3 matrixValue)
     {
         MatrixValue = matrixValue;
     }
-    
+
     public string ToXml()
     {
         string translateX = MatrixValue.TransX.ToString(CultureInfo.InvariantCulture);
@@ -25,7 +25,7 @@ public struct SvgTransformUnit : ISvgUnit
         string scaleY = MatrixValue.ScaleY.ToString(CultureInfo.InvariantCulture);
         string skewX = MatrixValue.SkewX.ToString(CultureInfo.InvariantCulture);
         string skewY = MatrixValue.SkewY.ToString(CultureInfo.InvariantCulture);
-        
+
         return $"matrix({scaleX}, {skewY}, {skewX}, {scaleY}, {translateX}, {translateY})";
     }
 
@@ -49,7 +49,101 @@ public struct SvgTransformUnit : ISvgUnit
         }
         else
         {
-            // todo: parse other types of transformation syntax (rotate, translate, scale etc)
+            MatrixValue = TryParseDescriptiveTransform(readerValue);
+        }
+    }
+
+    private Matrix3X3 TryParseDescriptiveTransform(string readerValue)
+    {
+        if (!readerValue.Contains('(') || !readerValue.Contains(')'))
+        {
+            return Matrix3X3.Identity;
+        }
+
+        string[] parts = readerValue.Split(')').Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
+
+        Matrix3X3 result = Matrix3X3.Identity;
+        for (int i = 0; i < parts.Length; i++)
+        {
+            string[] part = parts[i].Split('(');
+            if (part.Length != 2)
+            {
+                continue;
+            }
+
+            result = result.Concat(ParsePart(part));
+        }
+
+        return result;
+    }
+
+    private static Matrix3X3 ParsePart(string[] part)
+    {
+        string transformType = part[0].Trim();
+        string[] values = part[1].Split(' ', ')', ',').Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
+
+        if (values.Length == 0)
+        {
+            return Matrix3X3.Identity;
+        }
+
+        if (transformType == "translate")
+        {
+            if (float.TryParse(values[0], NumberStyles.Any, CultureInfo.InvariantCulture, out float translateX))
+            {
+                float translateY = translateX;
+                if (values.Length > 1)
+                {
+                    float.TryParse(values[1], NumberStyles.Any, CultureInfo.InvariantCulture, out translateY);
+                }
+
+                return Matrix3X3.CreateTranslation(translateX, translateY);
+            }
+        }
+        else if (transformType == "scale")
+        {
+            if (float.TryParse(values[0], NumberStyles.Any, CultureInfo.InvariantCulture, out float scaleX))
+            {
+                float scaleY = scaleX;
+                if (values.Length > 1)
+                {
+                    float.TryParse(values[1], NumberStyles.Any, CultureInfo.InvariantCulture, out scaleY);
+                }
+
+                return Matrix3X3.CreateScale(scaleX, scaleY);
+            }
         }
+        else if (transformType == "rotate")
+        {
+            if (float.TryParse(values[0], NumberStyles.Any, CultureInfo.InvariantCulture, out float angle))
+            {
+                float radians = angle * (float)Math.PI / 180;
+
+                if (values.Length > 2)
+                {
+                    float.TryParse(values[1], NumberStyles.Any, CultureInfo.InvariantCulture, out float centerX);
+                    float.TryParse(values[2], NumberStyles.Any, CultureInfo.InvariantCulture, out float centerY);
+                    return Matrix3X3.CreateRotation(radians, centerX, centerY);
+                }
+
+                return Matrix3X3.CreateRotation(radians);
+            }
+        }
+        else if (transformType == "skewX")
+        {
+            if (float.TryParse(values[0], NumberStyles.Any, CultureInfo.InvariantCulture, out float skewX))
+            {
+                return Matrix3X3.CreateSkew(skewX, 0);
+            }
+        }
+        else if (transformType == "skewY")
+        {
+            if (float.TryParse(values[0], NumberStyles.Any, CultureInfo.InvariantCulture, out float skewY))
+            {
+                return Matrix3X3.CreateSkew(0, skewY);
+            }
+        }
+
+        return Matrix3X3.Identity;
     }
 }

+ 8 - 2
src/PixiEditor.UI.Common/Accents/Base.axaml

@@ -54,12 +54,16 @@
             <Color x:Key="VecDSocketColor">#c984ca</Color>
             <Color x:Key="VecISocketColor">#c9b4ca</Color>
             <Color x:Key="IntSocketColor">#4C64B1</Color>
+            <Color x:Key="StringSocketColor">#C9E4C6</Color>
             <Color x:Key="EllipseDataSocketColor">#a473a5</Color>
             <Color x:Key="PointsDataSocketColor">#e1d0e1</Color>
+            <Color x:Key="TextDataSocketColor">#f2f2f2</Color>
             <GradientStops x:Key="ShapeDataSocketGradient">
                 <GradientStop Offset="0" Color="{StaticResource EllipseDataSocketColor}"/>
-                <GradientStop Offset="0.5" Color="{StaticResource EllipseDataSocketColor}"/>
-                <GradientStop Offset="0.5" Color="{StaticResource PointsDataSocketColor}"/>
+                <GradientStop Offset="0.33" Color="{StaticResource EllipseDataSocketColor}"/>
+                <GradientStop Offset="0.33" Color="{StaticResource TextDataSocketColor}"/>
+                <GradientStop Offset="0.66" Color="{StaticResource TextDataSocketColor}"/>
+                <GradientStop Offset="0.66" Color="{StaticResource PointsDataSocketColor}"/>
                 <GradientStop Offset="1" Color="{StaticResource PointsDataSocketColor}"/>
             </GradientStops>
             
@@ -142,9 +146,11 @@
             <SolidColorBrush x:Key="Int2SocketBrush" Color="{StaticResource VecISocketColor}"/>
             <SolidColorBrush x:Key="Int32SocketBrush" Color="{StaticResource IntSocketColor}"/>
             <SolidColorBrush x:Key="Int1SocketBrush" Color="{StaticResource IntSocketColor}"/>
+            <SolidColorBrush x:Key="StringSocketBrush" Color="{StaticResource StringSocketColor}"/>
             <ConicGradientBrush x:Key="ShapeVectorDataSocketBrush" GradientStops="{StaticResource ShapeDataSocketGradient}"/>
             <SolidColorBrush x:Key="EllipseVectorDataSocketBrush" Color="{StaticResource EllipseDataSocketColor}"/>
             <SolidColorBrush x:Key="PointsVectorDataSocketBrush" Color="{StaticResource PointsDataSocketColor}"/>
+            <SolidColorBrush x:Key="TextVectorDataSocketBrush" Color="{StaticResource TextDataSocketColor}"/>
             
             <!-- Zones & Frames -->
             <SolidColorBrush x:Key="PixiEditorModifyImageLeftBorderBrush" Color="{StaticResource PixiEditorModifyImageBorderColor}"/>

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


+ 49 - 29
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml

@@ -1,10 +1,10 @@
 <Styles xmlns="https://github.com/avaloniaui"
-                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-                    xmlns:system="clr-namespace:System;assembly=System.Runtime">
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:system="clr-namespace:System;assembly=System.Runtime">
     <Styles.Resources>
         <ResourceDictionary>
-            <FontFamily x:Key="PixiPerfectIcons">avares://PixiEditor.UI.Common/Fonts/PixiPerfect.ttf#PixiPerfect</FontFamily>
-            
+            <FontFamily x:Key="PixiPerfectIcons">avares://PixiEditor.UI.Common/Fonts/PixiPerfect.ttf#pixiperfect</FontFamily>
+
             <system:String x:Key="icon-add-reference">&#xE900;</system:String>
             <system:String x:Key="icon-add-to-mask">&#xE901;</system:String>
             <system:String x:Key="icon-alpha-lock">&#xE902;</system:String>
@@ -110,12 +110,13 @@
             <system:String x:Key="icon-zoom-out">&#xE962;</system:String>
             <system:String x:Key="icon-pen">&#xE971;</system:String>
             <system:String x:Key="icon-link">&#xE96B;</system:String>
+            <system:String x:Key="icon-upload">&#xE96D;</system:String>
             <system:String x:Key="icon-search">&#xE976;</system:String>
             <system:String x:Key="icon-hard-drive">&#xE96C;</system:String>
             <system:String x:Key="icon-copy-add">&#xe921;</system:String>
             <system:String x:Key="icon-check">&#xe96a;</system:String>
             <system:String x:Key="icon-dot">&#xe963;</system:String>
-            
+
             <system:String x:Key="icon-paste-reference-layer">&#xe977;</system:String>
             <system:String x:Key="icon-paste-as-new-layer">&#xe978;</system:String>
             <system:String x:Key="icon-star">&#xe97c;</system:String>
@@ -132,46 +133,65 @@
             <system:String x:Key="icon-swatches">&#xE982;</system:String>
             <system:String x:Key="icon-nodes">&#xe984;</system:String>
             <system:String x:Key="icon-onion">&#xe985;</system:String>
-            
+
             <system:String x:Key="icon-lowres-circle">&#xe986;</system:String>
             <system:String x:Key="icon-snapping">&#xe987;</system:String>
             <system:String x:Key="icon-lowres-square">&#xe988;</system:String>
             <system:String x:Key="icon-lowres-line">&#xe989;</system:String>
+
+            <system:String x:Key="icon-align-center">&#xE98C;</system:String>
+            <system:String x:Key="icon-bold">&#xE98D;</system:String>
+            <system:String x:Key="icon-text-antialiased">&#xE98E;</system:String>
+            <system:String x:Key="icon-italic">&#xE98F;</system:String>
+            <system:String x:Key="icon-align-stretch">&#xE990;</system:String>
+            <system:String x:Key="icon-align-left">&#xE991;</system:String>
+            <system:String x:Key="icon-letter-spacing">&#xE992;</system:String>
+            <system:String x:Key="icon-line-height">&#xE993;</system:String>
+            <system:String x:Key="icon-text-pixel">&#xE994;</system:String>
+            <system:String x:Key="icon-align-right">&#xE995;</system:String>
+            <system:String x:Key="icon-strikethrough">&#xE996;</system:String>
+            <system:String x:Key="icon-linked-pipette">&#xE997;</system:String>
+            <system:String x:Key="icon-text-underline">&#xE998;</system:String>
+            <system:String x:Key="icon-text-round">&#xE999;</system:String>
         </ResourceDictionary>
     </Styles.Resources>
-    
+
     <Style Selector="TextBlock.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
     </Style>
-    
+
+    <Style Selector="Label.pixi-icon">
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
+    </Style>
+
     <Style Selector="Run.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
     </Style>
-    
+
     <Style Selector="Button.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
-        <Setter Property="Background" Value="Transparent"/>
-        <Setter Property="FontSize" Value="24"/>
-        <Setter Property="Padding" Value="0"/>
-        <Setter Property="HorizontalContentAlignment" Value="Center"/>
-        <Setter Property="VerticalContentAlignment" Value="Center"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
+        <Setter Property="Background" Value="Transparent" />
+        <Setter Property="FontSize" Value="24" />
+        <Setter Property="Padding" Value="0" />
+        <Setter Property="HorizontalContentAlignment" Value="Center" />
+        <Setter Property="VerticalContentAlignment" Value="Center" />
     </Style>
-    
+
     <Style Selector="Button.pixi-icon:pointerover">
-        <Setter Property="Background" Value="Transparent"/>
+        <Setter Property="Background" Value="Transparent" />
     </Style>
-    
+
     <Style Selector="ToggleButton.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
-        <Setter Property="BorderThickness" Value="0"/>
-        <Setter Property="Background" Value="Transparent"/>
-        <Setter Property="FontSize" Value="24"/>
-        <Setter Property="Padding" Value="0"/>
-        <Setter Property="HorizontalContentAlignment" Value="Center"/>
-        <Setter Property="VerticalContentAlignment" Value="Center"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
+        <Setter Property="BorderThickness" Value="0" />
+        <Setter Property="Background" Value="Transparent" />
+        <Setter Property="FontSize" Value="24" />
+        <Setter Property="Padding" Value="0" />
+        <Setter Property="HorizontalContentAlignment" Value="Center" />
+        <Setter Property="VerticalContentAlignment" Value="Center" />
     </Style>
-    
+
     <Style Selector="ToggleButton.pixi-icon:checked">
-        <Setter Property="Background" Value="{DynamicResource ThemeControlHighBrush}"/>
+        <Setter Property="Background" Value="{DynamicResource ThemeControlHighBrush}" />
     </Style>
 </Styles>

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

@@ -128,7 +128,7 @@ public static class PixiPerfectIcons
     public const string Nodes = "\uE984";
     public const string Onion = "\uE965";
         
-    public const string Reset = "R"; // TODO: Create a reset icon
+    public const string Reset = "\uE98A";
     public const string ToggleLayerVisible = "\u25a1;"; // TODO: Create a toggle layer visible icon
     public const string ToggleMask = "\u25a1;"; // TODO: Create a toggle mask icon
     public const string Pen = "\uE971";
@@ -137,6 +137,21 @@ public static class PixiPerfectIcons
     public const string LowResSquare = "\uE988";
     public const string LowResLine = "\uE989";
 
+    public const string AlignCenter = "\uE98C";
+    public const string Bold = "\uE98D";
+    public const string TextAntialiased = "\uE98E";
+    public const string Italic = "\uE98F";
+    public const string AlignStretch = "\uE990";
+    public const string AlignLeft = "\uE991";
+    public const string LetterSpacing = "\uE992";
+    public const string LineHeight = "\uE993";
+    public const string TextPixel = "\uE994";
+    public const string AlignRight = "\uE995";
+    public const string Strikethrough = "\uE996";
+    public const string LinkedPipette = "\uE997";
+    public const string TextUnderline = "\uE998";
+    public const string TextRound = "\uE999";
+
     public static Stream GetFontStream()
     {
         return AssetLoader.Open(new Uri("avares://PixiEditor.UI.Common/Fonts/PixiPerfect.ttf"));

+ 0 - 1
src/PixiEditor/App.axaml

@@ -21,7 +21,6 @@
         <StyleInclude Source="/Styles/PixiEditor.Layers.axaml" />
         <StyleInclude Source="/Styles/PixiEditorPopupTemplate.axaml" />
         <StyleInclude Source="/Styles/Buttons/CaptionButtonsStyle.axaml" />
-        <StyleInclude Source="/Styles/NodeIcons.axaml"/>
     </Application.Styles>
     <Application.Resources>
         <ResourceDictionary>

+ 26 - 3
src/PixiEditor/Data/Configs/ToolSetsConfig.json

@@ -19,6 +19,14 @@
       "RasterLine",
       "RasterEllipse",
       "RasterRectangle",
+      {
+        "ToolName": "Text",
+        "Settings": {
+          "AntiAliasing": false,
+          "ForceLowDpiRendering": true
+        },
+        "Icon": "\ue994"
+      },
       {
         "ToolName": "Eraser",
         "Settings": {
@@ -42,7 +50,7 @@
           "AntiAliasing": true,
           "ExposeHardness": true,
           "ExposeSpacing": true,
-          "BrushShapeSetting": "CircleSmooth" 
+          "BrushShapeSetting": "CircleSmooth"
         }
       },
       "Select",
@@ -78,7 +86,15 @@
         "Settings": {
           "AntiAliasing": true
         },
-        "Icon": "\uE953" 
+        "Icon": "\uE953"
+      },
+      {
+        "ToolName": "Text",
+        "Settings": {
+          "AntiAliasing": true,
+          "ForceLowDpiRendering": true
+        },
+        "Icon": "\ue98E"
       },
       {
         "ToolName": "Eraser",
@@ -102,7 +118,14 @@
       "VectorPath",
       "VectorLine",
       "VectorEllipse",
-      "VectorRectangle"
+      "VectorRectangle",
+      {
+        "ToolName": "Text",
+        "Settings": {
+          "AntiAliasing": true,
+          "ForceLowDpiRendering": false
+        }
+      }
     ]
   }
 ]

+ 24 - 3
src/PixiEditor/Data/Localization/Languages/en.json

@@ -282,7 +282,7 @@
   "EDITOR_DATA": "Editor data (Local)",
   "MOVE_VIEWPORT_TOOLTIP": "Moves viewport. ({0})",
   "MOVE_VIEWPORT_ACTION_DISPLAY": "Click and move to pan the viewport",
-  "MOVE_TOOL_TOOLTIP": "Moves selected pixels ({0}). Hold Ctrl to move all layers.",
+  "MOVE_TOOL_TOOLTIP": "Select and transform layers ({0}).",
   "MOVE_TOOL_ACTION_DISPLAY": "Hold mouse to move selected pixels. Hold Ctrl to move all layers.",
   "PEN_TOOL_TOOLTIP": "Pen. ({0})",
   "PEN_TOOL_ACTION_DISPLAY": "Click and move to draw.",
@@ -799,7 +799,7 @@
   "PREVIOUS_TOOL_SET": "Previous tool set",
   "FILL_MODE": "Fill mode",
   "USE_LINEAR_SRGB_PROCESSING": "Use linear sRGB for processing colors",
-  "USE_LINEAR_SRGB_PROCESSING_DESC": "Convert document using legacy blending mode to linear sRGB for processing colors. This will affect the colors of the document, but will make blending more accurate.",
+  "USE_LINEAR_SRGB_PROCESSING_DESC": "Convert document using sRGB blending mode to linear sRGB for processing colors. This will affect the colors of the document, but will make blending more accurate.",
   "FILL_TYPE_WINDING": "Winding",
   "FILL_TYPE_EVEN_ODD": "Even Odd",
   "FILL_TYPE_INVERSE_WINDING": "Inverse Winding",
@@ -834,5 +834,26 @@
   "BLUR_FILTER_NODE": "Gaussian Blur Filter",
   "LENGTH": "Length",
   "GREATER_THAN_OR_EQUAL": "Greater than or equal",
-  "COLOR_NODE": "Color"
+  "COLOR_NODE": "Color",
+  "CONVERT_TO_CURVE": "Convert to curve",
+  "CONVERT_TO_CURVE_DESCRIPTIVE": "Convert selected vector layer to a curve/path",
+  "FONT_FILES": "Font Files",
+  "UNIT_PT": "pt",
+  "FONT_LABEL": "Family",
+  "FONT_SIZE_LABEL": "Size",
+  "SPACING_LABEL": "Spacing",
+  "TEXT_TOOL": "Text",
+  "MISSING_FONT": "Missing font",
+  "TEXT_LAYER_NAME": "Text",
+  "TEXT_TOOL_TOOLTIP": "Create text ({0}).",
+  "BOLD_TOOLTIP": "Bold",
+  "ITALIC_TOOLTIP": "Italic",
+  "CUSTOM_FONT": "Custom font",
+  "DUMP_GPU_DIAGNOSTICS": "Dump GPU diagnostics",
+  "USE_SRGB_PROCESSING": "Use sRGB for processing colors",
+  "USE_SRGB_PROCESSING_DESC": "Convert document using linear sRGB to sRGB for processing colors. This will affect the colors of the document.",
+  "TEXT_NODE": "Text",
+  "TEXT_LABEL": "Text",
+  "TEXT_ON_PATH_NODE": "Text on Path",
+  "HIGH_DPI_RENDERING": "High DPI Rendering"
 }

+ 18 - 0
src/PixiEditor/Helpers/Converters/FontFamilyNameToAvaloniaFontFamily.cs

@@ -0,0 +1,18 @@
+using System.Globalization;
+using Avalonia.Media;
+using Drawie.Backend.Core.Text;
+
+namespace PixiEditor.Helpers.Converters;
+
+internal class FontFamilyNameToAvaloniaFontFamily : SingleInstanceConverter<FontFamilyNameToAvaloniaFontFamily>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+    {
+        if (value is FontFamilyName familyName)
+        {
+            return new FontFamily(familyName.Name);
+        }
+
+        return value;
+    }
+}

+ 19 - 9
src/PixiEditor/Helpers/DocumentViewModelBuilder.cs

@@ -33,8 +33,9 @@ internal class DocumentViewModelBuilder
 
     public NodeGraphBuilder Graph { get; set; }
     public string ImageEncoderUsed { get; set; } = "QOI";
-    public bool UsesLegacyColorBlending { get; set; } = false;
+    public bool UsesSrgbColorBlending { get; set; } = false;
     public Version? PixiParserVersionUsed { get; set; }
+    public ResourceStorage DocumentResources { get; set; }
 
     public DocumentViewModelBuilder WithSize(int width, int height)
     {
@@ -126,9 +127,9 @@ internal class DocumentViewModelBuilder
         return this;
     }
 
-    public DocumentViewModelBuilder WithLegacyColorBlending(bool usesLegacyColorBlending)
+    public DocumentViewModelBuilder? WithSrgbColorBlending(bool usesLegacyColorBlending)
     {
-        UsesLegacyColorBlending = usesLegacyColorBlending;
+        UsesSrgbColorBlending = usesLegacyColorBlending;
         return this;
     }
 
@@ -148,11 +149,12 @@ internal class DocumentViewModelBuilder
 
             data?.Add(builder);
         }
-        
+
         TryAddMissingKeyFrames(root, data, documentGraph);
     }
 
-    private static void TryAddMissingKeyFrames(List<KeyFrameGroup> groups, List<KeyFrameBuilder>? data, NodeGraph documentGraph)
+    private static void TryAddMissingKeyFrames(List<KeyFrameGroup> groups, List<KeyFrameBuilder>? data,
+        NodeGraph documentGraph)
     {
         if (data == null)
         {
@@ -164,15 +166,15 @@ internal class DocumentViewModelBuilder
             if (node.KeyFrames.Length > 1 && data.All(x => x.NodeId != node.Id))
             {
                 GroupKeyFrameBuilder builder = new GroupKeyFrameBuilder()
-                .WithNodeId(node.Id);
-                
+                    .WithNodeId(node.Id);
+
                 foreach (var keyFrame in node.KeyFrames)
                 {
                     builder.WithChild<KeyFrameBuilder>(x => x
                         .WithKeyFrameId(keyFrame.Id)
                         .WithNodeId(node.Id));
-                }   
-                
+                }
+
                 data.Add(builder);
             }
         }
@@ -243,6 +245,12 @@ internal class DocumentViewModelBuilder
         PixiParserVersionUsed = version;
         return this;
     }
+
+    public DocumentViewModelBuilder WithResources(ResourceStorage documentResources)
+    {
+        DocumentResources = documentResources;
+        return this;
+    }
 }
 
 internal class AnimationDataBuilder
@@ -351,6 +359,7 @@ internal class NodeGraphBuilder
     {
         this.WithNodeOfType(typeof(ImageLayerNode))
             .WithName(name)
+            .WithPosition(new Vector2 { X = -250, Y = 0 })
             .WithId(AllNodes.Count)
             .WithKeyFrames(
             [
@@ -372,6 +381,7 @@ internal class NodeGraphBuilder
     {
         this.WithNodeOfType(typeof(ImageLayerNode))
             .WithName(name)
+            .WithPosition(new Vector2 { X = -250, Y = 0 })
             .WithId(AllNodes.Count)
             .WithKeyFrames(
             [

+ 4 - 2
src/PixiEditor/Helpers/Extensions/PixiParserDocumentEx.cs

@@ -1,6 +1,7 @@
 using Drawie.Backend.Core;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using Drawie.Numerics;
+using PixiEditor.Models.IO;
 using PixiEditor.Parser;
 using PixiEditor.Parser.Graph;
 using PixiEditor.Parser.Skia;
@@ -31,14 +32,15 @@ internal static class PixiParserDocumentEx
         return DocumentViewModel.Build(b => b
             .WithPixiParserVersion(document.Version)
             .WithSerializerData(document.SerializerName, document.SerializerVersion)
-            .WithLegacyColorBlending(document.LegacyColorBlending)
+            .WithSrgbColorBlending(document.SrgbColorBlending)
             .WithSize(document.Width, document.Height)
             .WithImageEncoder(document.ImageEncoderUsed)
             .WithPalette(document.Palette, color => new PaletteColor(color.R, color.G, color.B))
             .WithSwatches(document.Swatches, x => new(x.R, x.G, x.B))
             .WithReferenceLayer(document.ReferenceLayer, BuildReferenceLayer, encoder)
             .WithGraph(document.Graph, BuildGraph)
-            .WithAnimationData(document.AnimationData, document.Graph));
+            .WithAnimationData(document.AnimationData, document.Graph)
+            .WithResources(document.Resources));
     }
 
     private static void BuildGraph(NodeGraph graph, NodeGraphBuilder graphBuilder)

+ 4 - 0
src/PixiEditor/Helpers/ServiceCollectionHelpers.cs

@@ -100,6 +100,7 @@ internal static class ServiceCollectionHelpers
             .AddTool<IVectorRectangleToolHandler, VectorRectangleToolViewModel>()
             .AddTool<IVectorLineToolHandler, VectorLineToolViewModel>()
             .AddTool<IVectorPathToolHandler, VectorPathToolViewModel>()
+            .AddTool<ITextToolHandler, TextToolViewModel>()
             .AddTool<ZoomToolViewModel>()
             // File types
             .AddSingleton<IoFileType, PixiFileType>()
@@ -109,10 +110,13 @@ internal static class ServiceCollectionHelpers
             .AddSingleton<IoFileType, GifFileType>()
             .AddSingleton<IoFileType, Mp4FileType>()
             .AddSingleton<IoFileType, SvgFileType>()
+            .AddSingleton<IoFileType, TtfFileType>()
+            .AddSingleton<IoFileType, OtfFileType>()
             // Serialization Factories
             .AddAssemblyTypes<SerializationFactory>()
             // Custom document builders
             .AddSingleton<IDocumentBuilder, SvgDocumentBuilder>()
+            .AddSingleton<IDocumentBuilder, FontDocumentBuilder>()
             // Palette Parsers
             .AddSingleton<IPalettesProvider, PaletteProvider>()
             .AddSingleton<PaletteFileParser, JascFileParser>()

+ 2 - 2
src/PixiEditor/Helpers/SupportedFilesHelper.cs

@@ -70,7 +70,7 @@ internal class SupportedFilesHelper
 
     public static List<FilePickerFileType> BuildSaveFilter(FileTypeDialogDataSet.SetKind setKind = FileTypeDialogDataSet.SetKind.Any)
     {
-        var allSupportedExtensions = GetAllSupportedFileTypes(setKind);
+        var allSupportedExtensions = GetAllSupportedFileTypes(setKind).Where(x => x.CanSave).ToList();
         var filter = allSupportedExtensions.Select(i => i.SaveFilter).ToList();
 
         return filter;
@@ -84,7 +84,7 @@ internal class SupportedFilesHelper
             return null;
 
         string extension = Path.GetExtension(file.Path.LocalPath);
-        return allSupportedExtensions.Single(i => i.Extensions.Contains(extension));
+        return allSupportedExtensions.Single(i => i.CanSave && i.Extensions.Contains(extension));
     }
 
     public static List<FilePickerFileType> BuildOpenFilter()

+ 8 - 0
src/PixiEditor/Helpers/ThemeResources.cs

@@ -24,4 +24,12 @@ public static class ThemeResources
     public static Color BorderMidColor =>
         ResourceLoader.GetResource<SolidColorBrush>("ThemeBorderMidBrush", Application.Current.ActualThemeVariant).Color
             .ToColor();
+
+    public static Color ThemeControlHighlightColor =>
+        ResourceLoader.GetResource<Avalonia.Media.Color>("ThemeControlHighlightColor", Application.Current.ActualThemeVariant)
+            .ToColor();
+
+    public static Color SelectionFillColor =>
+        ResourceLoader.GetResource<Avalonia.Media.Color>("SelectionFillColor", Application.Current.ActualThemeVariant)
+            .ToColor();
 }

+ 1 - 1
src/PixiEditor/Initialization/ClassicDesktopEntry.cs

@@ -86,7 +86,7 @@ internal class ClassicDesktopEntry
             return;
         }
 
-#if !STEAM
+#if !STEAM && !DEBUG
         if (!HandleNewInstance(dispatcher))
         {
             return;

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

@@ -161,6 +161,11 @@ internal static class ClipboardController
 
         await Clipboard.SetDataObjectAsync(data);
     }
+    
+    public static async Task<string> GetTextFromClipboard()
+    {
+        return await Clipboard.GetTextAsync();
+    }
 
     private static async Task AddImageToClipboard(Surface actuallySurface, DataObject data)
     {
@@ -257,16 +262,26 @@ internal static class ClipboardController
             pos = dataObjectWithPos.GetVecD(ClipboardDataFormats.PositionFormat);
         }
 
+        RectD? tightBounds = null;
         for (var i = 0; i < layerIds.Length; i++)
         {
             var layerId = layerIds[i];
 
             var layer = doc.StructureHelper.Find(layerId);
-            if (layer is not { TightBounds: not null } || !layer.TightBounds.Value.Pos.AlmostEquals(pos))
-                return false;
+
+            if(layer == null) return false;
+
+            if(tightBounds == null)
+            {
+                tightBounds = layer.TightBounds;
+            }
+            else if(layer.TightBounds.HasValue)
+            {
+                tightBounds = tightBounds.Value.Union(layer.TightBounds.Value);
+            }
         }
 
-        return true;
+        return tightBounds.HasValue && tightBounds.Value.Pos.AlmostEquals(pos);
     }
 
     private static Guid[] GetLayerIds(IEnumerable<IDataObject> data)

+ 44 - 0
src/PixiEditor/Models/Controllers/FontLibrary.cs

@@ -0,0 +1,44 @@
+using System.Collections.ObjectModel;
+using Avalonia.Media;
+using Drawie.Backend.Core.Text;
+
+namespace PixiEditor.Models.Controllers;
+
+public static class FontLibrary
+{
+    private static List<FontFamilyName> _customFonts = new List<FontFamilyName>();
+    private static List<FontFamilyName> _allFonts = new List<FontFamilyName>();
+
+    public static FontFamilyName DefaultFontFamily { get; } = new FontFamilyName("Arial");
+
+    public static FontFamilyName[] SystemFonts { get; } = FontManager.Current.SystemFonts.Select(x => new FontFamilyName(x.Name)).ToArray();
+    
+    public static IReadOnlyList<FontFamilyName> CustomFonts => _customFonts;
+
+    public static FontFamilyName[] AllFonts
+    {
+        get
+        {
+            if (_allFonts.Count != SystemFonts.Length + CustomFonts.Count)
+            {
+                _allFonts = SystemFonts.Concat(CustomFonts).ToList();
+            }
+
+            return _allFonts.ToArray();
+        }
+    }
+
+    public static event Action<FontFamilyName> FontAdded;
+
+    public static bool TryAddCustomFont(FontFamilyName fontFamily)
+    {
+        if (!CustomFonts.Any(x => x.Name == fontFamily.Name && x.FontUri == fontFamily.FontUri))
+        {
+            _customFonts.Add(fontFamily);
+            FontAdded?.Invoke(fontFamily);
+            return true;
+        }
+        
+        return false;
+    }
+}

+ 3 - 3
src/PixiEditor/Models/Controllers/InputDevice/MouseInputFilter.cs

@@ -51,13 +51,13 @@ internal class MouseInputFilter
 
     public void DeactivatedInlet(object? sender, EventArgs e)
     {
-        MouseOnCanvasEventArgs argsLeft = new(MouseButton.Left, VecD.Zero, KeyModifiers.None);
+        MouseOnCanvasEventArgs argsLeft = new(MouseButton.Left, VecD.Zero, KeyModifiers.None, 0);
         MouseUpInlet(argsLeft);
         
-        MouseOnCanvasEventArgs argsMiddle = new(MouseButton.Middle, VecD.Zero, KeyModifiers.None);
+        MouseOnCanvasEventArgs argsMiddle = new(MouseButton.Middle, VecD.Zero, KeyModifiers.None, 0);
         MouseUpInlet(argsMiddle);
         
-        MouseOnCanvasEventArgs argsRight = new(MouseButton.Right, VecD.Zero, KeyModifiers.None);
+        MouseOnCanvasEventArgs argsRight = new(MouseButton.Right, VecD.Zero, KeyModifiers.None, 0);
         MouseUpInlet(argsRight);
     }
 }

+ 10 - 6
src/PixiEditor/Models/Controllers/InputDevice/MouseOnCanvasEventArgs.cs

@@ -3,17 +3,21 @@ using Drawie.Backend.Core.Numerics;
 using Drawie.Numerics;
 
 namespace PixiEditor.Models.Controllers.InputDevice;
+
 internal class MouseOnCanvasEventArgs : EventArgs
 {
-    public MouseOnCanvasEventArgs(MouseButton button, VecD positionOnCanvas, KeyModifiers keyModifiers)
+    public MouseButton Button { get; }
+    public VecD PositionOnCanvas { get; }
+    public KeyModifiers KeyModifiers { get; }
+    public bool Handled { get; set; }
+    
+    public int ClickCount { get; set; } = 1;
+
+    public MouseOnCanvasEventArgs(MouseButton button, VecD positionOnCanvas, KeyModifiers keyModifiers, int clickCount)
     {
         Button = button;
         PositionOnCanvas = positionOnCanvas;
         KeyModifiers = keyModifiers;
+        ClickCount = clickCount;
     }
-
-    public MouseButton Button { get; }
-    public VecD PositionOnCanvas { get; }
-    public KeyModifiers KeyModifiers { get; }
-    public bool Handled { get; set; }
 }

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

@@ -38,14 +38,14 @@ internal class ShortcutController
         _shortcutExecutionBlockers.Clear();
     }
 
-    public KeyCombination GetToolShortcut<T>()
+    public KeyCombination? GetToolShortcut<T>()
     {
         return GetToolShortcut(typeof(T));
     }
 
-    public KeyCombination GetToolShortcut(Type type)
+    public KeyCombination? GetToolShortcut(Type type)
     {
-        return CommandController.Current.Commands.First(x => x is Command.ToolCommand tool && tool.ToolType == type).Shortcut;
+        return CommandController.Current.Commands.FirstOrDefault(x => x is Command.ToolCommand tool && tool.ToolType == type)?.Shortcut;
     }
 
     public void KeyPressed(bool isRepeat, Key key, KeyModifiers modifiers)

+ 68 - 51
src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs

@@ -95,61 +95,78 @@ internal class ActionAccumulator
         };
         busyTimer.Start();
 
-        while (queuedActions.Count > 0)
+        try
         {
-            var toExecute = queuedActions;
-            queuedActions = new();
-
-            List<IChangeInfo?> changes;
-            if (AreAllPassthrough(toExecute))
-            {
-                changes = toExecute.Select(a => (IChangeInfo?)a.action).ToList();
-            }
-            else
-            {
-                changes = await internals.Tracker.ProcessActions(toExecute);
-            }
-
-            List<IChangeInfo> optimizedChanges = ChangeInfoListOptimizer.Optimize(changes);
-            bool undoBoundaryPassed =
-                toExecute.Any(static action => action.action is ChangeBoundary_Action or Redo_Action or Undo_Action);
-            bool viewportRefreshRequest =
-                toExecute.Any(static action => action.action is RefreshViewport_PassthroughAction);
-            bool changeFrameRequest =
-                toExecute.Any(static action => action.action is SetActiveFrame_PassthroughAction);
-            foreach (IChangeInfo info in optimizedChanges)
-            {
-                internals.Updater.ApplyChangeFromChangeInfo(info);
-            }
-
-            if (undoBoundaryPassed)
-                internals.Updater.AfterUndoBoundaryPassed();
-
-            // update the contents of the bitmaps
-            var affectedAreas = new AffectedAreasGatherer(document.AnimationHandler.ActiveFrameTime, internals.Tracker,
-                optimizedChanges);
-            if (DrawingBackendApi.Current.IsHardwareAccelerated)
+            while (queuedActions.Count > 0)
             {
-                canvasUpdater.UpdateGatheredChunksSync(affectedAreas,
-                    undoBoundaryPassed || viewportRefreshRequest);
-            }
-            else
-            {
-                await canvasUpdater.UpdateGatheredChunks(affectedAreas,
-                    undoBoundaryPassed || viewportRefreshRequest);
-            }
-
-            previewUpdater.UpdatePreviews(undoBoundaryPassed || changeFrameRequest || viewportRefreshRequest, affectedAreas.ImagePreviewAreas.Keys,
-                affectedAreas.MaskPreviewAreas.Keys,
-                affectedAreas.ChangedNodes, affectedAreas.ChangedKeyFrames);
-
-            // force refresh viewports for better responsiveness
-            foreach (var (_, value) in internals.State.Viewports)
-            {
-                if (!value.Delayed)
-                    value.InvalidateVisual();
+                var toExecute = queuedActions;
+                queuedActions = new();
+
+                List<IChangeInfo?> changes;
+                if (AreAllPassthrough(toExecute))
+                {
+                    changes = toExecute.Select(a => (IChangeInfo?)a.action).ToList();
+                }
+                else
+                {
+                    changes = await internals.Tracker.ProcessActions(toExecute);
+                }
+
+                List<IChangeInfo> optimizedChanges = ChangeInfoListOptimizer.Optimize(changes);
+                bool undoBoundaryPassed =
+                    toExecute.Any(static action =>
+                        action.action is ChangeBoundary_Action or Redo_Action or Undo_Action);
+                bool viewportRefreshRequest =
+                    toExecute.Any(static action => action.action is RefreshViewport_PassthroughAction);
+                bool changeFrameRequest =
+                    toExecute.Any(static action => action.action is SetActiveFrame_PassthroughAction);
+                foreach (IChangeInfo info in optimizedChanges)
+                {
+                    internals.Updater.ApplyChangeFromChangeInfo(info);
+                }
+
+                if (undoBoundaryPassed)
+                    internals.Updater.AfterUndoBoundaryPassed();
+
+                // update the contents of the bitmaps
+                var affectedAreas = new AffectedAreasGatherer(document.AnimationHandler.ActiveFrameTime,
+                    internals.Tracker,
+                    optimizedChanges);
+                if (DrawingBackendApi.Current.IsHardwareAccelerated)
+                {
+                    canvasUpdater.UpdateGatheredChunksSync(affectedAreas,
+                        undoBoundaryPassed || viewportRefreshRequest);
+                }
+                else
+                {
+                    await canvasUpdater.UpdateGatheredChunks(affectedAreas,
+                        undoBoundaryPassed || viewportRefreshRequest);
+                }
+
+                previewUpdater.UpdatePreviews(undoBoundaryPassed || changeFrameRequest || viewportRefreshRequest,
+                    affectedAreas.ImagePreviewAreas.Keys,
+                    affectedAreas.MaskPreviewAreas.Keys,
+                    affectedAreas.ChangedNodes, affectedAreas.ChangedKeyFrames);
+
+                // force refresh viewports for better responsiveness
+                foreach (var (_, value) in internals.State.Viewports)
+                {
+                    if (!value.Delayed)
+                        value.InvalidateVisual();
+                }
             }
         }
+        catch (Exception e)
+        {
+            busyTimer.Stop();
+            document.Busy = false;
+            executing = false;
+#if DEBUG
+            Console.WriteLine(e);
+#endif
+            await CrashHelper.SendExceptionInfoAsync(e);
+            throw;
+        }
 
         busyTimer.Stop();
         if (document.Busy)

+ 34 - 2
src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs

@@ -221,12 +221,28 @@ internal class ChangeExecutionController
         currentSession?.OnLeftMouseButtonUp(argsPositionOnCanvas);
     }
 
-    public void TransformMovedInlet(ShapeCorners corners)
+    public void TransformChangedInlet(ShapeCorners corners)
     {
         if (currentSession is ITransformableExecutor transformableExecutor)
         {
             LastTransformState = corners;
-            transformableExecutor.OnTransformMoved(corners);
+            transformableExecutor.OnTransformChanged(corners);
+        }
+    }
+    
+    public void TransformDraggedInlet(VecD from, VecD to)
+    {
+        if (currentSession is ITransformDraggedEvent transformableExecutor)
+        {
+            transformableExecutor.OnTransformDragged(from, to);
+        }
+    }
+
+    public void TransformStoppedInlet()
+    {
+        if(currentSession is ITransformStoppedEvent transformStoppedEvent)
+        {
+            transformStoppedEvent.OnTransformStopped();
         }
     }
 
@@ -289,4 +305,20 @@ internal class ChangeExecutionController
             vectorPathToolExecutor.OnPathChanged(path);
         }
     }
+
+    public void TextOverlayTextChangedInlet(string text)
+    {
+        if (currentSession is ITextOverlayEvents textOverlayHandler)
+        {
+            textOverlayHandler.OnTextChanged(text);
+        }
+    }
+
+    public void QuickToolSwitchInlet()
+    {
+        if (currentSession is IQuickToolSwitchable quickToolSwitchable)
+        {
+            quickToolSwitchable.OnQuickToolSwitch();
+        }
+    }
 }

+ 2 - 0
src/PixiEditor/Models/DocumentModels/Public/DocumentEventsModule.cs

@@ -51,4 +51,6 @@ internal class DocumentEventsModule
     public void OnSymmetryDragStarted(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragStartedInlet(dir);
     public void OnSymmetryDragged(SymmetryAxisDragInfo info) => Internals.ChangeController.SymmetryDraggedInlet(info);
     public void OnSymmetryDragEnded(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragEndedInlet(dir);
+
+    public void QuickToolSwitchInlet() => Internals.ChangeController.QuickToolSwitchInlet();
 }

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

@@ -89,7 +89,7 @@ internal class DocumentOperationsModule : IDocumentOperations
         bool drawOnMask = member is not ILayerHandler layer || layer.ShouldDrawOnMask;
         if (drawOnMask && !member.HasMaskBindable)
             return;
-        Internals.ActionAccumulator.AddActions(new ClearSelectedArea_Action(member.Id, drawOnMask, frame));
+        Internals.ActionAccumulator.AddActions(new ClearSelectedArea_Action(member.Id, Internals.Tracker.Document.Selection.SelectionPath, drawOnMask, frame));
         if (clearSelection)
             Internals.ActionAccumulator.AddActions(new ClearSelection_Action());
         Internals.ActionAccumulator.AddFinishedActions();
@@ -210,13 +210,13 @@ internal class DocumentOperationsModule : IDocumentOperations
         bool isFolder = Document.StructureHelper.Find(guidValue) is IFolderHandler;
         if (!isFolder)
         {
-            Internals.ActionAccumulator.AddFinishedActions(new DuplicateLayer_Action(guidValue));
+            Internals.ActionAccumulator.AddFinishedActions(new DuplicateLayer_Action(guidValue, Guid.NewGuid()));
         }
         else
         {
             Guid newGuid = Guid.NewGuid();
             Internals.ActionAccumulator.AddFinishedActions(
-                new DuplicateFolder_Action(guidValue, newGuid),
+                new DuplicateFolder_Action(guidValue, newGuid, null),
                 new SetSelectedMember_PassthroughAction(newGuid));
         }
     }
@@ -881,7 +881,8 @@ internal class DocumentOperationsModule : IDocumentOperations
 
         Internals.ChangeController.TryStopActiveExecutor();
 
-        Internals.ActionAccumulator.AddFinishedActions(new KeyFrameLength_Action(celId, startFrame, duration), new EndKeyFrameLength_Action());
+        Internals.ActionAccumulator.AddFinishedActions(new KeyFrameLength_Action(celId, startFrame, duration),
+            new EndKeyFrameLength_Action());
     }
 
     public void DeleteNodes(Guid[] nodes)
@@ -890,7 +891,7 @@ internal class DocumentOperationsModule : IDocumentOperations
             return;
 
         Internals.ChangeController.TryStopActiveExecutor();
-        
+
         List<IAction> actions = new();
 
         for (var i = 0; i < nodes.Length; i++)
@@ -899,10 +900,20 @@ internal class DocumentOperationsModule : IDocumentOperations
             if (Document.StructureHelper.TryFindNode(node, out INodeHandler nodeHandler) &&
                 nodeHandler.InternalName == OutputNode.UniqueName)
                 return;
-            
+
             actions.Add(new DeleteNode_Action(node));
         }
 
         Internals.ActionAccumulator.AddFinishedActions(actions.ToArray());
     }
+
+    public void ConvertToCurve(Guid memberId)
+    {
+        if (Internals.ChangeController.IsBlockingChangeActive)
+            return;
+
+        Internals.ChangeController.TryStopActiveExecutor();
+
+        Internals.ActionAccumulator.AddFinishedActions(new ConvertToCurve_Action(memberId));
+    }
 }

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

@@ -146,7 +146,7 @@ internal class DocumentStructureModule
         return layers;
     }
 
-    public List<IStructureMemberHandler> GetAllMembers()
+    public List<IStructureMemberHandler> TraverseAllMembers()
     {
         List<IStructureMemberHandler> members = new List<IStructureMemberHandler>();
 
@@ -160,6 +160,19 @@ internal class DocumentStructureModule
         return members;
     }
 
+    public List<IStructureMemberHandler> GetAllMembers()
+    {
+        List<IStructureMemberHandler> members = new List<IStructureMemberHandler>();
+
+        foreach (INodeHandler node in doc.NodeGraphHandler.AllNodes)
+        {
+            if (node is IStructureMemberHandler member)
+                members.Add(member);
+        }
+
+        return members;
+    }
+
     private void FillPath(INodeHandler node, List<INodeHandler> toFill)
     {
         node.TraverseForwards(newNode =>

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

@@ -21,7 +21,6 @@ internal class DocumentToolsModule
 
     public void UseOpacitySlider() => Internals.ChangeController.TryStartExecutor<StructureMemberOpacityExecutor>();
 
-    public void UseShiftLayerTool() => Internals.ChangeController.TryStartExecutor<ShiftLayerExecutor>();
 
     public void UsePenTool() => Internals.ChangeController.TryStartExecutor<PenToolExecutor>();
 
@@ -80,4 +79,10 @@ internal class DocumentToolsModule
     public void UseLassoTool() => Internals.ChangeController.TryStartExecutor<LassoToolExecutor>();
 
     public void UseMagicWandTool() => Internals.ChangeController.TryStartExecutor<MagicWandToolExecutor>();
+
+    public void UseTextTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<VectorTextToolExecutor>(force);
+    }
 }

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/DrawableShapeToolExecutor.cs

@@ -140,7 +140,7 @@ internal abstract class DrawableShapeToolExecutor<T> : SimpleShapeToolExecutor w
         return pos1;
     }
 
-    public override void OnTransformMoved(ShapeCorners corners)
+    public override void OnTransformChanged(ShapeCorners corners)
     {
         if (ActiveMode != ShapeToolMode.Transform)
             return;

+ 6 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/IQuickToolSwitchable.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+
+internal interface IQuickToolSwitchable : IExecutorFeature
+{
+    public void OnQuickToolSwitch();
+}

+ 6 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITextOverlayEvents.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+
+public interface ITextOverlayEvents :IExecutorFeature
+{
+    public void OnTextChanged(string text);
+}

+ 9 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITransformDraggedEvent.cs

@@ -0,0 +1,9 @@
+using ChunkyImageLib.DataHolders;
+using Drawie.Numerics;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+
+public interface ITransformDraggedEvent : IExecutorFeature
+{
+    public void OnTransformDragged(VecD from, VecD to);
+}

+ 6 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITransformStoppedEvent.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+
+public interface ITransformStoppedEvent : IExecutorFeature
+{
+    public void OnTransformStopped();
+}

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITransformableExecutor.cs

@@ -6,7 +6,7 @@ namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
 public interface ITransformableExecutor : IExecutorFeature
 {
     public bool IsTransforming { get; }
-    public void OnTransformMoved(ShapeCorners corners); 
+    public void OnTransformChanged(ShapeCorners corners); 
     public void OnTransformApplied();
     public void OnLineOverlayMoved(VecD start, VecD end);
     public void OnSelectedObjectNudged(VecI distance);

+ 4 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/LineExecutor.cs

@@ -79,6 +79,9 @@ internal abstract class LineExecutor<T> : SimpleShapeToolExecutor where T : ILin
         if (member is IVectorLayerHandler)
         {
             var node = (VectorLayerNode)internals.Tracker.Document.FindMember(member.Id);
+            
+            if(node is null)
+                return ExecutionState.Error;
 
             if (node.ShapeData is not IReadOnlyLineData data)
             {
@@ -94,7 +97,7 @@ internal abstract class LineExecutor<T> : SimpleShapeToolExecutor where T : ILin
 
             ActiveMode = ShapeToolMode.Transform;
 
-            document.LineToolOverlayHandler.Show(data.Start, data.End, false, AddToUndo);
+            document.LineToolOverlayHandler.Show(data.TransformedStart, data.TransformedEnd, false, AddToUndo);
         }
         else
         {

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/MagicWandToolExecutor.cs

@@ -20,7 +20,7 @@ internal class MagicWandToolExecutor : UpdateableChangeExecutor
     public override ExecutionState Start()
     {
         var magicWand = GetHandler<IMagicWandToolHandler>();
-        var members = document!.ExtractSelectedLayers(true);
+        var members = document!.ExtractSelectedLayers(true).ToList();
 
         if (magicWand is null || members.Count == 0)
             return ExecutionState.Error;

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs

@@ -62,7 +62,7 @@ internal class PasteImageExecutor : UpdateableChangeExecutor, ITransformableExec
 
     public bool IsTransforming => true; 
 
-    public void OnTransformMoved(ShapeCorners corners)
+    public void OnTransformChanged(ShapeCorners corners)
     {
         internals!.ActionAccumulator.AddActions(new PasteImage_Action(image, corners, memberGuid.Value, false, drawOnMask, document!.AnimationHandler.ActiveFrameBindable, default));
     }

+ 0 - 74
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/ShiftLayerExecutor.cs

@@ -1,74 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using PixiEditor.ChangeableDocument.Actions.Generated;
-using Drawie.Backend.Core.Numerics;
-using PixiEditor.Models.Handlers;
-using PixiEditor.Models.Handlers.Tools;
-using PixiEditor.Models.Tools;
-using Drawie.Numerics;
-
-namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
-#nullable enable
-internal class ShiftLayerExecutor : UpdateableChangeExecutor
-{
-    private List<Guid> _affectedMemberGuids = new List<Guid>();
-    private VecI startPos;
-    private IMoveToolHandler? tool;
-
-    public override ExecutorStartMode StartMode => ExecutorStartMode.OnMouseLeftButtonDown;
-
-    public override ExecutionState Start()
-    {
-        IStructureMemberHandler? member = document!.SelectedStructureMember;
-
-        tool = GetHandler<IMoveToolHandler>();
-        if (tool is null)
-            return ExecutionState.Error;
-
-
-        if (member != null)
-            _affectedMemberGuids.Add(member.Id);
-        _affectedMemberGuids.AddRange(document!.SoftSelectedStructureMembers.Select(x => x.Id));
-
-        RemoveDrawOnMaskLayers(_affectedMemberGuids);
-
-        startPos = controller!.LastPixelPosition;
-
-        ShiftLayer_Action action = new(_affectedMemberGuids, VecI.Zero, tool.KeepOriginalImage,
-            document!.AnimationHandler.ActiveFrameBindable);
-        internals!.ActionAccumulator.AddActions(action);
-
-        return ExecutionState.Success;
-    }
-
-    private void RemoveDrawOnMaskLayers(List<Guid> affectedMemberGuids)
-    {
-        for (var i = 0; i < affectedMemberGuids.Count; i++)
-        {
-            var guid = affectedMemberGuids[i];
-            if (document!.StructureHelper.FindOrThrow(guid) is ILayerHandler { ShouldDrawOnMask: true })
-            {
-                _affectedMemberGuids.Remove(guid);
-                i--;
-            }
-        }
-    }
-
-    public override void OnPixelPositionChange(VecI pos)
-    {
-        ShiftLayer_Action action = new(_affectedMemberGuids, pos - startPos, tool!.KeepOriginalImage,
-            document!.AnimationHandler.ActiveFrameBindable);
-        internals!.ActionAccumulator.AddActions(action);
-    }
-
-    public override void OnLeftMouseButtonUp(VecD argsPositionOnCanvas)
-    {
-        internals!.ActionAccumulator.AddFinishedActions(new EndShiftLayer_Action());
-        onEnded?.Invoke(this);
-    }
-
-    public override void ForceStop()
-    {
-        internals!.ActionAccumulator.AddFinishedActions(new EndShiftLayer_Action());
-    }
-}

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/SimpleShapeToolExecutor.cs

@@ -145,7 +145,7 @@ internal abstract class SimpleShapeToolExecutor : UpdateableChangeExecutor,
 
     public bool IsTransforming => ActiveMode == ShapeToolMode.Transform; 
 
-    public virtual void OnTransformMoved(ShapeCorners corners)
+    public virtual void OnTransformChanged(ShapeCorners corners)
     {
         
     }

+ 1 - 1
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformReferenceLayerExecutor.cs

@@ -22,7 +22,7 @@ internal class TransformReferenceLayerExecutor : UpdateableChangeExecutor, ITran
 
     public bool IsTransforming => true;
 
-    public void OnTransformMoved(ShapeCorners corners)
+    public void OnTransformChanged(ShapeCorners corners)
     {
         internals!.ActionAccumulator.AddActions(new TransformReferenceLayer_Action(corners));
     }

+ 180 - 4
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedExecutor.cs

@@ -1,21 +1,29 @@
 using System.Collections.Generic;
+using System.Collections.Immutable;
 using System.Linq;
 using Avalonia.Input;
 using ChunkyImageLib.DataHolders;
 using PixiEditor.ChangeableDocument.Actions.Generated;
 using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Vector;
 using PixiEditor.Models.DocumentModels.Public;
 using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
 using PixiEditor.Models.Handlers;
 using PixiEditor.Models.Handlers.Tools;
 using PixiEditor.Models.Tools;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.Models.Controllers.InputDevice;
+using PixiEditor.Models.DocumentPassthroughActions;
+using PixiEditor.ViewModels;
 using PixiEditor.ViewModels.Document.Nodes;
 
 namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
 #nullable enable
-internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransformableExecutor, IMidChangeUndoableExecutor
+internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransformableExecutor, ITransformDraggedEvent,
+    IMidChangeUndoableExecutor,
+    ITransformStoppedEvent
 {
     private Dictionary<Guid, ShapeCorners> memberCorners = new();
     private IMoveToolHandler? tool;
@@ -25,9 +33,12 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
     public override bool BlocksOtherActions => false;
 
     private List<Guid> selectedMembers = new();
+    private List<Guid> originalSelectedMembers = new();
 
+    private ShapeCorners cornersOnStartDuplicate;
     private ShapeCorners lastCorners = new();
     private bool movedOnce;
+    private bool duplicateOnStop = false;
 
     public TransformSelectedExecutor(bool toolLinked)
     {
@@ -42,7 +53,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
 
         tool.TransformingSelectedArea = true;
         List<IStructureMemberHandler> members = new();
-
+        originalSelectedMembers = document.SelectedMembers.ToList();
         var guids = document.ExtractSelectedLayers(false);
         members = guids.Select(g => document.StructureHelper.Find(g)).ToList();
 
@@ -50,6 +61,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
             return ExecutionState.Error;
 
         document.TransformHandler.PassthroughPointerPressed += OnLeftMouseButtonDown;
+
         return SelectMembers(members);
     }
 
@@ -115,6 +127,8 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         selectedMembers = members.Select(m => m.Id).ToList();
 
         lastCorners = masterCorners;
+
+
         document.TransformHandler.ShowTransform(mode, true, masterCorners,
             Type == ExecutorType.Regular || tool.KeepOriginalImage);
 
@@ -122,12 +136,22 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
 
         movedOnce = false;
         isInProgress = true;
+
         return ExecutionState.Success;
     }
 
     public override void OnLeftMouseButtonDown(MouseOnCanvasEventArgs args)
     {
         args.Handled = true;
+
+        if (args.ClickCount >= 2)
+        {
+            if (SwitchToLayerTool())
+            {
+                return;
+            }
+        }
+
         var allLayers = document.StructureHelper.GetAllLayers();
         var topMostWithinClick = allLayers.Where(x =>
                 x is { IsVisibleBindable: true, TightBounds: not null } &&
@@ -164,6 +188,23 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         }
     }
 
+    private bool SwitchToLayerTool()
+    {
+        if (document.SelectedStructureMember is ILayerHandler layerHandler && layerHandler.QuickEditTool != null)
+        {
+            ViewModelMain.Current.ToolsSubViewModel.SetActiveTool(layerHandler.QuickEditTool, false);
+            ViewModelMain.Current.ToolsSubViewModel.QuickToolSwitchInlet();
+            return true;
+        }
+
+        return false;
+    }
+
+    public void OnTransformStopped()
+    {
+        DuplicateIfRequired();
+    }
+
     private void Deselect(List<ILayerHandler> topMostWithinClick)
     {
         var topMost = topMostWithinClick.FirstOrDefault();
@@ -212,6 +253,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         if (isInProgress)
         {
             internals.ActionAccumulator.AddActions(new EndTransformSelected_Action());
+            internals!.ActionAccumulator.AddActions(new EndPreviewShiftLayers_Action());
             document!.TransformHandler.HideTransform();
             AddSnappingForMembers(selectedMembers);
 
@@ -223,22 +265,48 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
 
     public bool IsTransforming => isInProgress;
 
-    public void OnTransformMoved(ShapeCorners corners)
+    public void OnTransformChanged(ShapeCorners corners)
     {
         DoTransform(corners);
         lastCorners = corners;
     }
 
+    public void OnTransformDragged(VecD from, VecD to)
+    {
+        if (!isInProgress)
+            return;
+
+        if (tool.DuplicateOnMove)
+        {
+            if (!duplicateOnStop)
+            {
+                cornersOnStartDuplicate = lastCorners;
+                duplicateOnStop = true;
+                internals.ActionAccumulator.AddFinishedActions(new EndTransformSelected_Action());
+            }
+
+            VecD delta = new VecD(
+                to.X - from.X,
+                to.Y - from.Y);
+
+            internals.ActionAccumulator.AddActions(new PreviewShiftLayers_Action(selectedMembers, delta,
+                document!.AnimationHandler.ActiveFrameBindable));
+        }
+    }
+
     private void DoTransform(ShapeCorners corners)
     {
         if (!isInProgress)
             return;
 
+        if (duplicateOnStop) return;
+
         if (!movedOnce)
         {
             internals!.ActionAccumulator.AddActions(
                 new TransformSelected_Action(lastCorners, tool.KeepOriginalImage, memberCorners, false,
                     document.AnimationHandler.ActiveFrameBindable));
+
             movedOnce = true;
         }
 
@@ -247,6 +315,101 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
                 document!.AnimationHandler.ActiveFrameBindable));
     }
 
+    private void DuplicateSelected()
+    {
+        List<IAction> actions = new();
+
+        List<Guid> newLayerGuids = new();
+        List<Guid> newGuidsOfOriginal = new();
+
+        internals.ActionAccumulator.StartChangeBlock();
+
+        actions.Add(new EndPreviewShiftLayers_Action());
+
+        VectorPath? original = document.SelectionPathBindable != null
+            ? new VectorPath(document.SelectionPathBindable)
+            : null;
+
+        VectorPath? clearArea = null;
+        if (original != null)
+        {
+            var selection = document.SelectionPathBindable;
+            var inverse = new VectorPath();
+            inverse.AddRect(new RectD(new(0, 0), document.SizeBindable));
+
+            clearArea = inverse.Op(selection, VectorPathOp.Difference);
+        }
+
+        for (var i = 0; i < originalSelectedMembers.Count; i++)
+        {
+            var member = originalSelectedMembers[i];
+            Guid newGuid = Guid.NewGuid();
+            if (document.StructureHelper.Find(member) is not FolderNodeViewModel folder)
+            {
+                newLayerGuids.Add(newGuid);
+                actions.Add(new DuplicateLayer_Action(member, newGuid));
+                if (document.SelectionPathBindable is { IsEmpty: false })
+                {
+                    actions.Add(new ClearSelectedArea_Action(newGuid, clearArea, false,
+                        document.AnimationHandler.ActiveFrameBindable));
+                }
+            }
+            else
+            {
+                int childCount = folder.CountChildrenRecursive();
+                Guid[] newGuidsArray = new Guid[childCount];
+                for (var j = 0; j < childCount; j++)
+                {
+                    newGuidsArray[j] = Guid.NewGuid();
+                }
+
+                actions.Add(new DuplicateFolder_Action(member, newGuid, newGuidsArray.ToImmutableList()));
+
+                for (int j = 0; j < childCount; j++)
+                {
+                    if (document.SelectionPathBindable is { IsEmpty: false })
+                    {
+                        actions.Add(new ClearSelectedArea_Action(newGuidsArray[j], clearArea, false,
+                            document.AnimationHandler.ActiveFrameBindable));
+                    }
+                }
+
+                newLayerGuids.AddRange(newGuidsArray);
+            }
+
+            newGuidsOfOriginal.Add(newGuid);
+        }
+
+        internals!.ActionAccumulator.AddFinishedActions(actions.ToArray());
+
+        actions.Clear();
+
+        VecD delta = new VecD(
+            lastCorners.AABBBounds.TopLeft.X - cornersOnStartDuplicate.AABBBounds.TopLeft.X,
+            lastCorners.AABBBounds.TopLeft.Y - cornersOnStartDuplicate.AABBBounds.TopLeft.Y);
+
+        actions.Add(new ShiftLayer_Action(newLayerGuids, delta, document!.AnimationHandler.ActiveFrameBindable));
+
+        internals!.ActionAccumulator.AddFinishedActions(actions.ToArray());
+
+        actions.Clear();
+
+        actions.Add(new ClearSoftSelectedMembers_PassthroughAction());
+        foreach (var newGuid in newGuidsOfOriginal)
+        {
+            actions.Add(new AddSoftSelectedMember_PassthroughAction(newGuid));
+        }
+
+        actions.Add(new SetSelectedMember_PassthroughAction(newGuidsOfOriginal.Last()));
+
+        internals!.ActionAccumulator.AddFinishedActions(actions.ToArray());
+
+
+        internals.ActionAccumulator.EndChangeBlock();
+
+        tool!.DuplicateOnMove = false;
+    }
+
     public void OnLineOverlayMoved(VecD start, VecD end) { }
 
     public void OnSelectedObjectNudged(VecI distance) => document!.TransformHandler.Nudge(distance);
@@ -264,6 +427,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
             tool.TransformingSelectedArea = false;
         }
 
+        internals!.ActionAccumulator.AddActions(new EndPreviewShiftLayers_Action());
         internals!.ActionAccumulator.AddActions(new EndTransformSelected_Action());
         internals!.ActionAccumulator.AddFinishedActions();
         document!.TransformHandler.HideTransform();
@@ -286,6 +450,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
             tool.TransformingSelectedArea = false;
         }
 
+        internals!.ActionAccumulator.AddActions(new EndPreviewShiftLayers_Action());
         internals!.ActionAccumulator.AddActions(new EndTransformSelected_Action());
         internals!.ActionAccumulator.AddFinishedActions();
         document!.TransformHandler.HideTransform();
@@ -293,6 +458,16 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
 
         isInProgress = false;
         document.TransformHandler.PassthroughPointerPressed -= OnLeftMouseButtonDown;
+        DuplicateIfRequired();
+    }
+
+    private void DuplicateIfRequired()
+    {
+        if (duplicateOnStop)
+        {
+            DuplicateSelected();
+            duplicateOnStop = false;
+        }
     }
 
     private void AddSnappingForMembers(List<Guid> memberGuids)
@@ -314,6 +489,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
 
     public bool IsFeatureEnabled(IExecutorFeature feature)
     {
-        return feature is ITransformableExecutor && IsTransforming;
+        return feature is ITransformableExecutor && IsTransforming || feature is IMidChangeUndoableExecutor ||
+               feature is ITransformStoppedEvent || feature is ITransformDraggedEvent;
     }
 }

+ 10 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs

@@ -62,4 +62,14 @@ internal abstract class UpdateableChangeExecutor
     public virtual void OnSettingsChanged(string name, object value) { }
     public virtual void OnColorChanged(Color color, bool primary) { }
     public virtual void OnMembersSelected(List<Guid> memberGuids) { }
+
+    protected T[] QueryLayers<T>(VecD pos) where T : ILayerHandler
+    {
+        var allLayers = document.StructureHelper.GetAllLayers();
+        var topMostWithinClick = allLayers.Where(x =>
+                x is T { IsVisibleBindable: true, TightBounds: not null } &&
+                x.TightBounds.Value.ContainsInclusive(pos))
+            .OrderByDescending(x => allLayers.IndexOf(x));
+        return topMostWithinClick.Cast<T>().ToArray();
+    }
 }

+ 2 - 2
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorLineToolExecutor.cs

@@ -24,8 +24,8 @@ internal class VectorLineToolExecutor : LineExecutor<IVectorLineToolHandler>
         if (data is null)
             return false;
 
-        startPoint = data.Start;
-        endPoint = data.End;
+        startPoint = data.TransformedStart;
+        endPoint = data.TransformedEnd;
 
         return true;
     }

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

@@ -0,0 +1,268 @@
+using Avalonia.Input;
+using Avalonia.Threading;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Controllers.InputDevice;
+using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+using PixiEditor.Models.Handlers;
+using PixiEditor.Models.Handlers.Toolbars;
+using PixiEditor.Models.Handlers.Tools;
+using PixiEditor.Models.Tools;
+using PixiEditor.ViewModels;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+
+internal class VectorTextToolExecutor : UpdateableChangeExecutor, ITextOverlayEvents, IQuickToolSwitchable
+{
+    private ITextToolHandler textHandler;
+    private ITextToolbar toolbar;
+    private IStructureMemberHandler selectedMember;
+
+    private string lastText = "";
+    private VecD position;
+    private Matrix3X3 lastMatrix = Matrix3X3.Identity;
+    private Font? cachedFont;
+    private bool isListeningForValidLayer;
+    private VectorPath? onPath;
+
+    public override bool BlocksOtherActions => false;
+
+    public override ExecutorType Type => ExecutorType.ToolLinked;
+
+    public override ExecutionState Start()
+    {
+        textHandler = GetHandler<ITextToolHandler>();
+        if (textHandler == null)
+        {
+            return ExecutionState.Error;
+        }
+
+        toolbar = textHandler.Toolbar as ITextToolbar;
+        if (toolbar == null)
+        {
+            return ExecutionState.Error;
+        }
+
+        selectedMember = document.SelectedStructureMember;
+
+        if (selectedMember is not IVectorLayerHandler layerHandler)
+        {
+            isListeningForValidLayer = true;
+            return ExecutionState.Success;
+        }
+
+        isListeningForValidLayer = false;
+        var shape = layerHandler.GetShapeData(document.AnimationHandler.ActiveFrameBindable);
+        if (shape is TextVectorData textData)
+        {
+            document.TextOverlayHandler.Show(textData.Text, textData.Position, textData.Font,
+                textData.TransformationMatrix, textData.Spacing);
+
+            toolbar.Fill = textData.Fill;
+            toolbar.FillColor = textData.FillColor.ToColor();
+            toolbar.StrokeColor = textData.StrokeColor.ToColor();
+            toolbar.ToolSize = textData.StrokeWidth;
+            toolbar.FontFamily = textData.Font.Family;
+            toolbar.FontSize = textData.Font.Size;
+            toolbar.Spacing = textData.Spacing ?? textData.Font.Size;
+            toolbar.Bold = textData.Font.Bold;
+            toolbar.Italic = textData.Font.Italic;
+
+            onPath = textData.Path;
+            lastText = textData.Text;
+            position = textData.Position;
+            lastMatrix = textData.TransformationMatrix;
+        }
+        else if (shape is null)
+        {
+            document.TextOverlayHandler.Show("", controller.LastPrecisePosition, toolbar.ConstructFont(),
+                Matrix3X3.Identity, toolbar.Spacing);
+            lastText = "";
+            position = controller.LastPrecisePosition;
+            // TODO: Implement proper putting on path editing
+            /*if (controller.LeftMousePressed)
+            {
+                TryPutOnPath(controller.LastPrecisePosition);
+            }*/
+        }
+        else
+        {
+            return ExecutionState.Error;
+        }
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnLeftMouseButtonDown(MouseOnCanvasEventArgs args)
+    {
+        var topMostWithinClick = QueryLayers<IVectorLayerHandler>(args.PositionOnCanvas);
+
+        var firstLayer = topMostWithinClick.FirstOrDefault();
+        args.Handled = firstLayer != null;
+        if (firstLayer is not IVectorLayerHandler layerHandler)
+        {
+            if (document.TextOverlayHandler.IsActive)
+            {
+                args.Handled = true;
+                document.TextOverlayHandler.Hide();
+            }
+
+            return;
+        }
+
+        document.Operations.SetSelectedMember(layerHandler.Id);
+        document.Operations.InvokeCustomAction(
+            () =>
+            {
+                document.TextOverlayHandler.SetCursorPosition(args.PositionOnCanvas);
+            }, false);
+    }
+
+    public void OnQuickToolSwitch()
+    {
+        document.TextOverlayHandler.SetCursorPosition(internals.ChangeController.LastPrecisePosition);
+    }
+
+    public override void ForceStop()
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndSetShapeGeometry_Action());
+        document.TextOverlayHandler.Hide();
+    }
+
+    public void OnTextChanged(string text)
+    {
+        var constructedText = ConstructTextData(text);
+        internals.ActionAccumulator.AddFinishedActions(
+            new SetShapeGeometry_Action(selectedMember.Id, constructedText),
+            new EndSetShapeGeometry_Action(),
+            new SetLowDpiRendering_Action(selectedMember.Id, toolbar.ForceLowDpiRendering));
+        lastText = text;
+        document.TextOverlayHandler.Font = constructedText.Font;
+    }
+
+    public override void OnSettingsChanged(string name, object value)
+    {
+        if (isListeningForValidLayer)
+        {
+            return;
+        }
+
+        if (name == nameof(ITextToolbar.FontFamily))
+        {
+            Font toDispose = cachedFont;
+            Dispatcher.UIThread.Post(() =>
+            {
+                toDispose?.Dispose();
+            });
+
+            cachedFont = toolbar.ConstructFont();
+            document.TextOverlayHandler.Font = cachedFont;
+        }
+        else
+        {
+            if (cachedFont == null)
+            {
+                cachedFont = toolbar.ConstructFont();
+            }
+
+            document.TextOverlayHandler.Font.Size = toolbar.FontSize;
+            cachedFont.Size = toolbar.FontSize;
+            cachedFont.Bold = toolbar.Bold;
+            cachedFont.Italic = toolbar.Italic;
+        }
+
+        var constructedText = ConstructTextData(lastText);
+        internals.ActionAccumulator.AddActions(
+            new SetShapeGeometry_Action(selectedMember.Id, constructedText),
+            new SetLowDpiRendering_Action(selectedMember.Id, toolbar.ForceLowDpiRendering));
+
+        document.TextOverlayHandler.Font = constructedText.Font;
+        document.TextOverlayHandler.Spacing = toolbar.Spacing;
+    }
+
+    public override void OnColorChanged(Color color, bool primary)
+    {
+        if (!primary || !toolbar.SyncWithPrimaryColor)
+        {
+            return;
+        }
+
+        toolbar.StrokeColor = color.ToColor();
+        toolbar.FillColor = color.ToColor();
+    }
+
+    private void TryPutOnPath(VecD pos)
+    {
+        var topMostWithinClick = QueryLayers<IVectorLayerHandler>(pos);
+        var firstValidLayer = topMostWithinClick.FirstOrDefault(x =>
+            x.GetShapeData(document.AnimationHandler.ActiveFrameTime) is not null and not TextVectorData);
+
+        if (firstValidLayer is null)
+        {
+            return;
+        }
+
+        var shape = firstValidLayer.GetShapeData(document.AnimationHandler.ActiveFrameTime);
+
+        ShapeVectorData newShape = (ShapeVectorData)(shape as ShapeVectorData).Clone();
+
+        newShape.Fill = false;
+        newShape.StrokeWidth = 0;
+
+        onPath = newShape.ToPath();
+
+        var constructedText = ConstructTextData(lastText);
+        internals.ActionAccumulator.AddFinishedActions(
+            new SetShapeGeometry_Action(selectedMember.Id, constructedText),
+            new EndSetShapeGeometry_Action(),
+            new SetLowDpiRendering_Action(selectedMember.Id, toolbar.ForceLowDpiRendering),
+            new SetShapeGeometry_Action(firstValidLayer.Id, newShape),
+            new EndSetShapeGeometry_Action());
+    }
+
+    private TextVectorData ConstructTextData(string text)
+    {
+        if (cachedFont == null || cachedFont.Family.Name != toolbar.FontFamily.Name)
+        {
+            Font toDispose = cachedFont;
+            Dispatcher.UIThread.Post(() =>
+            {
+                toDispose?.Dispose();
+            });
+            cachedFont = toolbar.ConstructFont();
+        }
+        else
+        {
+            cachedFont.Size = toolbar.FontSize;
+        }
+
+        return new TextVectorData()
+        {
+            Text = text,
+            Position = position,
+            Fill = toolbar.Fill,
+            FillColor = toolbar.FillColor.ToColor(),
+            StrokeWidth = (float)toolbar.ToolSize,
+            StrokeColor = toolbar.StrokeColor.ToColor(),
+            TransformationMatrix = lastMatrix,
+            Font = cachedFont,
+            Spacing = toolbar.Spacing,
+            AntiAlias = toolbar.AntiAliasing,
+            Path = onPath,
+            // TODO: MaxWidth = toolbar.MaxWidth
+            // TODO: Path
+        };
+    }
+
+    bool IExecutorFeature.IsFeatureEnabled(IExecutorFeature feature)
+    {
+        return feature is ITextOverlayEvents || feature is IQuickToolSwitchable;
+    }
+}

+ 2 - 0
src/PixiEditor/Models/Files/IoFileType.cs

@@ -39,6 +39,8 @@ internal abstract class IoFileType
     {
         get { return Extensions.Select(GetExtensionFormattedForDialog).ToList(); }
     }
+
+    public virtual bool CanSave => true;
     
     string GetExtensionFormattedForDialog(string extension)
     {

+ 18 - 0
src/PixiEditor/Models/Files/OtfFileType.cs

@@ -0,0 +1,18 @@
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.Files;
+
+internal class OtfFileType : IoFileType
+{
+    public override string[] Extensions { get; } = new[] { ".otf" };
+    public override string DisplayName { get; } = "OpenType Font";
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
+
+    public override bool CanSave => false;
+
+    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving OTF files is not supported.");
+    }
+}

+ 2 - 0
src/PixiEditor/Models/Files/SvgFileType.cs

@@ -26,6 +26,8 @@ internal class SvgFileType : IoFileType
         job?.Report(0.5, string.Empty); 
         string xml = svgDocument.ToXml();
 
+        xml = $"<!-- Created with PixiEditor (https://pixieditor.net) -->{Environment.NewLine}" + xml;
+
         job?.Report(0.75, string.Empty);
         await using FileStream fileStream = new(pathWithExtension, FileMode.Create);
         await using StreamWriter writer = new(fileStream);

+ 18 - 0
src/PixiEditor/Models/Files/TtfFileType.cs

@@ -0,0 +1,18 @@
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.Files;
+
+internal class TtfFileType : IoFileType
+{
+    public override string[] Extensions { get; } = new[] { ".ttf" };
+    public override string DisplayName { get; } = "TrueType Font";
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
+
+    public override bool CanSave => false;
+
+    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving TTF files is not supported.");
+    }
+}

+ 6 - 2
src/PixiEditor/Models/Handlers/IDocument.cs

@@ -36,6 +36,7 @@ internal interface IDocument : IHandler
     public IReadOnlyCollection<IStructureMemberHandler> SoftSelectedStructureMembers { get; }
     public ITransformHandler TransformHandler { get; }
     public IPathOverlayHandler PathOverlayHandler { get; }
+    public ITextOverlayHandler TextOverlayHandler { get; }
     public bool Busy { get; set; }
     public ILineOverlayHandler LineToolOverlayHandler { get; }
     public bool HorizontalSymmetryAxisEnabledBindable { get; }
@@ -57,8 +58,11 @@ internal interface IDocument : IHandler
     public void UpdateSelectionPath(VectorPath infoNewPath);
     public void SetProcessingColorSpace(ColorSpace infoColorSpace);
     public void SetSize(VecI infoSize);
-    public Color PickColor(VecD controllerLastPrecisePosition, DocumentScope scope, bool includeReference, bool includeCanvas, int frame, bool isTopMost);
-    public List<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false);
+
+    public Color PickColor(VecD controllerLastPrecisePosition, DocumentScope scope, bool includeReference,
+        bool includeCanvas, int frame, bool isTopMost);
+
+    public HashSet<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false);
     public void UpdateSavedState();
 
     internal void InternalRaiseLayersChanged(LayersChangedEventArgs e);

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

@@ -12,4 +12,5 @@ internal interface IDocumentOperations
     public void SetSelectedMember(Guid memberId);
     public void ClearSoftSelectedMembers();
     public Guid? CreateStructureMember(Type type, ActionSource source, string? name = null);
+    public void InvokeCustomAction(Action action, bool stopActiveExecutor = true);
 }

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

@@ -3,4 +3,5 @@
 internal interface ILayerHandler : IStructureMemberHandler
 {
     public bool ShouldDrawOnMask { get; set; }
+    public Type? QuickEditTool { get; }
 }

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

@@ -0,0 +1,16 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+
+namespace PixiEditor.Models.Handlers;
+
+public interface ITextOverlayHandler : IHandler
+{
+    public void Show(string text, VecD position, Font font, Matrix3X3 matrix, double? spacing = null);
+    public void Hide();
+    public Font Font { get; set; }
+    public VecD Position { get; set; }
+    public double? Spacing { get; set; }
+    public bool IsActive { get; }
+    public void SetCursorPosition(VecD closestToPosition);
+}

+ 6 - 3
src/PixiEditor/Models/Handlers/IToolHandler.cs

@@ -56,14 +56,17 @@ internal interface IToolHandler : IHandler
 
     public virtual string? DefaultNewLayerName => null;
 
-    public void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown);
+    public void KeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown, Key argsKey);
     public void UseTool(VecD pos);
     public void OnToolSelected(bool restoring);
 
     public void SetToolSetSettings(IToolSetHandler toolset, Dictionary<string, object>? settings);
     public void ApplyToolSetSettings(IToolSetHandler toolset);
     public void OnToolDeselected(bool transient);
-    public void OnPostUndo();
-    public void OnPostRedo();
+    public void OnPostUndoInlet();
+    public void OnPostRedoInlet();
     public void OnActiveFrameChanged(int newFrame);
+    public void OnPreUndoInlet();
+    public void OnPreRedoInlet();
+    public void QuickToolSwitchInlet();
 }

+ 3 - 0
src/PixiEditor/Models/Handlers/IToolsHandler.cs

@@ -33,4 +33,7 @@ internal interface IToolsHandler : IHandler
     public void AddPropertyChangedCallback(string propertyName, Action callback);
     public void OnPostUndoInlet();
     public void OnPostRedoInlet();
+    public void OnPreRedoInlet();
+    public void OnPreUndoInlet();
+    public void QuickToolSwitchInlet();
 }

+ 15 - 0
src/PixiEditor/Models/Handlers/Toolbars/ITextToolbar.cs

@@ -0,0 +1,15 @@
+using Drawie.Backend.Core.Text;
+
+namespace PixiEditor.Models.Handlers.Toolbars;
+
+internal interface ITextToolbar : IFillableShapeToolbar
+{
+    public double FontSize { get; set; }
+    public FontFamilyName FontFamily { get; set; }
+    public double Spacing { get; set; }
+    public bool ForceLowDpiRendering { get; set; }
+    public bool Bold { get; set; }
+    public bool Italic { get; set; }
+
+    public Font ConstructFont();
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác