Sfoglia il codice sorgente

Merge branch 'master' into pixiauth

Krzysztof Krysiński 2 mesi fa
parent
commit
dfbc825ab1
26 ha cambiato i file con 381 aggiunte e 61 eliminazioni
  1. 2 2
      README.md
  2. 1 1
      src/ColorPicker
  3. 1 1
      src/Drawie
  4. 9 0
      src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs
  5. 11 1
      src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs
  6. 14 8
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs
  7. 4 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextOnPathNode.cs
  8. 5 2
      src/PixiEditor.ChangeableDocument/Changes/Animation/CreateCel_Change.cs
  9. 5 0
      src/PixiEditor.ChangeableDocument/Changes/Animation/DeleteKeyFrame_Change.cs
  10. 6 0
      src/PixiEditor.UI.Common/Controls/NumberInput.cs
  11. 8 1
      src/PixiEditor/Data/Localization/Languages/en.json
  12. 83 0
      src/PixiEditor/Helpers/Behaviours/SliderBindingBehavior.cs
  13. 40 10
      src/PixiEditor/Helpers/Extensions/ColorHelpers.cs
  14. 1 1
      src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs
  15. 1 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorPathToolExecutor.cs
  16. 1 0
      src/PixiEditor/Models/IO/ExportConfig.cs
  17. 54 0
      src/PixiEditor/Models/IO/Exporter.cs
  18. 10 1
      src/PixiEditor/Models/Serialization/Factories/TextSerializationFactory.cs
  19. 2 2
      src/PixiEditor/Properties/AssemblyInfo.cs
  20. 4 0
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  21. 2 2
      src/PixiEditor/Views/Animations/Timeline.cs
  22. 5 2
      src/PixiEditor/Views/Dialogs/ExportFileDialog.cs
  23. 21 1
      src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml
  24. 73 24
      src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs
  25. 8 2
      src/PixiEditor/Views/Nodes/Properties/DoublePropertyView.axaml
  26. 10 0
      src/PixiEditor/Views/Overlays/PathOverlay/VectorPathOverlay.cs

+ 2 - 2
README.md

@@ -1,7 +1,7 @@
 <img src="https://github.com/user-attachments/assets/bd08c8bd-f610-449d-b1e2-6a990e562518">
 
 
-**PixiEditor** is a universal 2D platform that aims to provide you with tools and features for all your 2D needs. Create beautiful sprites for your games, animations, edit images, create logos. All packed in an eye-friendly dark theme.     
+**PixiEditor** is a universal 2D editor that aims to provide you with tools and features for all your 2D needs. Create beautiful sprites for your games, animations, edit images, create logos. All packed in an eye-friendly dark theme.     
 
 
 [![Release](https://img.shields.io/github/v/release/flabbet/PixiEditor)](https://github.com/flabbet/PixiEditor/releases) 
@@ -14,7 +14,7 @@
 
 ## About PixiEditor
 
-PixiEditor aims to be all-in-one solution for 2D image editing, we aim to achieve this by building a solid foundation with basic functionalities, and exposing complex extension system, that would customize PixiEditor for all your needs.
+PixiEditor aims to be all-in-one solution for 2D image editing, we want to achieve this by building a solid foundation with built-in tools for editing raster and vector graphics, procedural artworks, animations and more. To fully customize PixiEditor for all of your 2D needs, we built advanced Node Graph rendering, that allows for creating basically anything. From tiled texturing workspace, procedural animations that wouldn't be possible to make by hand, to even rendering 3D shapes.
 
 The project started as a pixel-art editor, but quickly evolved into something much more complex. Version 1.0 was downloaded over 100 000 times on all platforms and received 93% positive rating on Steam.
 

+ 1 - 1
src/ColorPicker

@@ -1 +1 @@
-Subproject commit 56faff5afcc6fc96b44b5caa6f15d0b3a902cd0a
+Subproject commit 66bae8cf20153b9273b10c7d37ac90dc57ef15bb

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit 3b9c90113ed6b7600d1b80828d3ddf1d70ead90a
+Subproject commit 34ce7ab4d8e14f5dbad86dea076fe4c7cda15da3

+ 9 - 0
src/PixiEditor.AnimationRenderer.Core/IAnimationRenderer.cs

@@ -7,3 +7,12 @@ public interface IAnimationRenderer
     public Task<bool> RenderAsync(List<Image> imageStream, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback);
     public bool Render(List<Image> imageStream, string outputPath, CancellationToken cancellationToken, Action<double>? progressCallback);
 }
+
+public enum QualityPreset
+{
+    VeryLow = 0,
+    Low = 1,
+    Medium = 2,
+    High = 3,
+    VeryHigh = 4,
+}

+ 11 - 1
src/PixiEditor.AnimationRenderer.FFmpeg/FFMpegRenderer.cs

@@ -17,6 +17,7 @@ public class FFMpegRenderer : IAnimationRenderer
     public int FrameRate { get; set; } = 60;
     public string OutputFormat { get; set; } = "mp4";
     public VecI Size { get; set; }
+    public QualityPreset QualityPreset { get; set; } = QualityPreset.VeryHigh;
 
     public async Task<bool> RenderAsync(List<Image> rawFrames, string outputPath, CancellationToken cancellationToken,
         Action<double>? progressCallback = null)
@@ -215,11 +216,20 @@ public class FFMpegRenderer : IAnimationRenderer
 
     private FFMpegArgumentProcessor GetMp4Arguments(FFMpegArguments args, string outputPath)
     {
+        int qscale = QualityPreset switch
+        {
+            QualityPreset.VeryLow => 31,
+            QualityPreset.Low => 25,
+            QualityPreset.Medium => 19,
+            QualityPreset.High => 10,
+            QualityPreset.VeryHigh => 1,
+            _ => 2
+        };
         return args
             .OutputToFile(outputPath, true, options =>
             {
                 options.WithFramerate(FrameRate)
-                    .WithVideoBitrate(1800)
+                    .WithCustomArgument($"-qscale:v {qscale}")
                     .WithVideoCodec("mpeg4")
                     .ForcePixelFormat("yuv420p");
             });

+ 14 - 8
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs

@@ -153,6 +153,7 @@ public class TextVectorData : ShapeVectorData, IReadOnlyTextData, IScalable
 
     public FontFamilyName? MissingFontFamily { get; set; }
     public string MissingFontText { get; set; }
+    public VecD PathOffset { get; set; }
 
     private RichText richText;
     private RectD lastBounds;
@@ -226,7 +227,7 @@ public class TextVectorData : ShapeVectorData, IReadOnlyTextData, IScalable
 
     private void PaintText(Canvas canvas, Paint paint)
     {
-        richText.Paint(canvas, Position, Font, paint, Path);
+        richText.Paint(canvas, Position, Font, paint, Path, PathOffset);
     }
 
     public override bool IsValid()
@@ -239,11 +240,14 @@ public class TextVectorData : ShapeVectorData, IReadOnlyTextData, IScalable
         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;
+            if (textData.Font != null)
+            {
+                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;
         }
@@ -262,6 +266,7 @@ public class TextVectorData : ShapeVectorData, IReadOnlyTextData, IScalable
         hash.Add(MaxWidth);
         hash.Add(Bold);
         hash.Add(Italic);
+        hash.Add(PathOffset);
 
         return hash.ToHashCode();
     }
@@ -288,7 +293,8 @@ public class TextVectorData : ShapeVectorData, IReadOnlyTextData, IScalable
     protected bool Equals(TextVectorData other)
     {
         return base.Equals(other) && Position.Equals(other.Position) && MaxWidth.Equals(other.MaxWidth) && AntiAlias == other.AntiAlias && Nullable.Equals(MissingFontFamily, other.MissingFontFamily) && MissingFontText == other.MissingFontText
-            && Text == other.Text && Font.Equals(other.Font) && Spacing.Equals(other.Spacing) && Path == other.Path && Bold == other.Bold && Italic == other.Italic;
+            && Text == other.Text && Font.Equals(other.Font) && Spacing.Equals(other.Spacing) && Path == other.Path && Bold == other.Bold && Italic == other.Italic
+            && PathOffset.Equals(other.PathOffset);
     }
 
     public override bool Equals(object? obj)
@@ -313,6 +319,6 @@ public class TextVectorData : ShapeVectorData, IReadOnlyTextData, IScalable
 
     public override int GetHashCode()
     {
-        return HashCode.Combine(base.GetHashCode(), Position, MaxWidth, AntiAlias, MissingFontFamily, MissingFontText, Font, HashCode.Combine(Text, Spacing, Path));
+        return HashCode.Combine(base.GetHashCode(), Position, MaxWidth, AntiAlias, MissingFontFamily, MissingFontText, Font, HashCode.Combine(Text, Spacing, Path, PathOffset));
     }
 }

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

@@ -1,4 +1,5 @@
 using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 using PixiEditor.ChangeableDocument.Rendering;
 
@@ -9,6 +10,7 @@ public class TextOnPathNode : Node
 {
     public InputProperty<TextVectorData> TextData { get; }
     public InputProperty<ShapeVectorData> PathData { get; }
+    public InputProperty<VecD> Offset { get; }
 
     public OutputProperty<TextVectorData> Output { get; }
 
@@ -18,6 +20,7 @@ public class TextOnPathNode : Node
     {
         TextData = CreateInput<TextVectorData>("Text", "TEXT_LABEL", null);
         PathData = CreateInput<ShapeVectorData>("Path", "SHAPE_LABEL", null);
+        Offset = CreateInput<VecD>("Offset", "OFFSET", VecD.Zero);
 
         Output = CreateOutput<TextVectorData>("Output", "TEXT_LABEL", null);
     }
@@ -40,6 +43,7 @@ public class TextOnPathNode : Node
         lastPath.Transform(pathData.TransformationMatrix);
 
         cloned.Path = lastPath;
+        cloned.PathOffset = Offset.Value;
 
         Output.Value = cloned;
     }

+ 5 - 2
src/PixiEditor.ChangeableDocument/Changes/Animation/CreateCel_Change.cs

@@ -34,9 +34,12 @@ internal class CreateCel_Change : Change
             return false;
         }
         
-        if(_frame == -1 && targetLayer.KeyFrames.All(x => x.KeyFrameGuid != createdKeyFrameId))
+        if(_frame == -1)
         {
-            return false;
+            if (targetLayer.KeyFrames.All(x => x.KeyFrameGuid != createdKeyFrameId) || targetLayer.KeyFrames.Count <= 1)
+            {
+                return false;
+            }
         }
         
         return _frame != 0 && target.TryFindMember(_targetLayerGuid, out _layer);

+ 5 - 0
src/PixiEditor.ChangeableDocument/Changes/Animation/DeleteKeyFrame_Change.cs

@@ -26,6 +26,11 @@ internal class DeleteKeyFrame_Change : Change
                 return false;
             }
 
+            if(node.KeyFrames.FirstOrDefault()?.KeyFrameGuid == keyFrame.Id) // If the keyframe is the first one, we cannot delete it.
+            {
+                return false;
+            }
+
             clonedKeyFrame = keyFrame.Clone();
             
             KeyFrameData data = node.KeyFrames.FirstOrDefault(x => x.KeyFrameGuid == keyFrame.Id);

+ 6 - 0
src/PixiEditor.UI.Common/Controls/NumberInput.cs

@@ -314,6 +314,12 @@ public partial class NumberInput : TextBox
 
     private static bool TryParse(string s, out double value)
     {
+        if (s == null)
+        {
+            value = 0;
+            return false;
+        }
+
         s = s.Replace(",", ".");
 
         if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out value))

+ 8 - 1
src/PixiEditor/Data/Localization/Languages/en.json

@@ -1091,5 +1091,12 @@
   "STEP_START": "Step back to closest cel",
   "STEP_END": "Step forward to closest cel",
   "STEP_FORWARD": "Step forward one frame",
-  "STEP_BACK": "Step back one frame"
+  "STEP_BACK": "Step back one frame",
+  "ANIMATION_QUALITY_PRESET": "Quality Preset",
+  "VERY_LOW_QUALITY_PRESET": "Very Low",
+  "LOW_QUALITY_PRESET": "Low",
+  "MEDIUM_QUALITY_PRESET": "Medium",
+  "HIGH_QUALITY_PRESET": "High",
+  "VERY_HIGH_QUALITY_PRESET": "Very High",
+  "EXPORT_FRAMES": "Export Frames"
 }

+ 83 - 0
src/PixiEditor/Helpers/Behaviours/SliderBindingBehavior.cs

@@ -0,0 +1,83 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Xaml.Interactivity;
+
+namespace PixiEditor.Helpers.Behaviours;
+
+public class SliderBindingBehavior : Behavior<Slider>
+{
+    public static readonly StyledProperty<bool> CanBindProperty = AvaloniaProperty.Register<SliderBindingBehavior, bool>(
+        nameof(CanBind));
+
+    public static readonly StyledProperty<IBinding?> ValueBindingProperty = AvaloniaProperty.Register<SliderBindingBehavior, IBinding?>(
+        nameof(ValueBinding));
+
+    [AssignBinding]
+    public IBinding? ValueBinding
+    {
+        get => GetValue(ValueBindingProperty);
+        set => SetValue(ValueBindingProperty, value);
+    }
+
+    public bool CanBind
+    {
+        get => GetValue(CanBindProperty);
+        set => SetValue(CanBindProperty, value);
+    }
+
+    static SliderBindingBehavior()
+    {
+        CanBindProperty.Changed.Subscribe(OnCanBindChanged);
+        ValueBindingProperty.Changed.Subscribe(OnBindingChanged);
+    }
+
+    protected override void OnAttached()
+    {
+        base.OnAttached();
+        if (AssociatedObject != null)
+        {
+            if (CanBind)
+            {
+                AssociatedObject.Bind(
+                    RangeBase.ValueProperty,
+                    ValueBinding);
+            }
+        }
+    }
+
+    private static void OnCanBindChanged(AvaloniaPropertyChangedEventArgs<bool> e)
+    {
+        if (e.Sender is SliderBindingBehavior behavior && behavior.AssociatedObject != null)
+        {
+            if (e.NewValue.Value && behavior.ValueBinding != null)
+            {
+                behavior.AssociatedObject.Bind(
+                    RangeBase.ValueProperty,
+                    behavior.ValueBinding);
+            }
+            else
+            {
+                behavior.AssociatedObject.ClearValue(RangeBase.ValueProperty);
+            }
+        }
+    }
+
+    private static void OnBindingChanged(AvaloniaPropertyChangedEventArgs<IBinding> e)
+    {
+        if (e.Sender is SliderBindingBehavior behavior && behavior.AssociatedObject != null)
+        {
+            if (behavior.CanBind && e.NewValue.Value != null)
+            {
+                behavior.AssociatedObject.Bind(
+                    RangeBase.ValueProperty,
+                    e.NewValue.Value);
+            }
+            else
+            {
+                behavior.AssociatedObject.ClearValue(RangeBase.ValueProperty);
+            }
+        }
+    }
+}

+ 40 - 10
src/PixiEditor/Helpers/Extensions/ColorHelpers.cs

@@ -1,7 +1,10 @@
 using Avalonia;
 using Avalonia.Media;
+using Avalonia.Skia;
 using Drawie.Backend.Core.ColorsImpl.Paintables;
+using Drawie.Backend.Core.Numerics;
 using Drawie.Numerics;
+using Drawie.Skia;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using BackendColor = Drawie.Backend.Core.ColorsImpl.Color;
 using GradientStop = Drawie.Backend.Core.ColorsImpl.Paintables.GradientStop;
@@ -47,17 +50,31 @@ internal static class ColorHelpers
                 new VecD(linearGradientBrush.StartPoint.Point.X, linearGradientBrush.StartPoint.Point.Y),
                 new VecD(linearGradientBrush.EndPoint.Point.X, linearGradientBrush.EndPoint.Point.Y),
                 linearGradientBrush.GradientStops.Select(stop =>
-                    new GradientStop(new BackendColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A), stop.Offset))),
+                    new GradientStop(new BackendColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A), stop.Offset)))
+            {
+                AbsoluteValues = linearGradientBrush.StartPoint.Unit == RelativeUnit.Absolute ||
+                                 linearGradientBrush.EndPoint.Unit == RelativeUnit.Absolute,
+                Transform = linearGradientBrush.Transform != null ? ToDrawieMatrix(linearGradientBrush.Transform.Value) : null
+            },
         IRadialGradientBrush radialGradientBrush => new RadialGradientPaintable(
             new VecD(radialGradientBrush.Center.Point.X, radialGradientBrush.Center.Point.Y),
             radialGradientBrush.RadiusX.Scalar,
             radialGradientBrush.GradientStops.Select(stop =>
-                new GradientStop(new BackendColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A), stop.Offset))),
+                new GradientStop(new BackendColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A), stop.Offset)))
+        {
+            AbsoluteValues = radialGradientBrush.Center.Unit == RelativeUnit.Absolute ||
+                             radialGradientBrush.RadiusX.Unit == RelativeUnit.Absolute,
+            Transform = radialGradientBrush.Transform != null ? ToDrawieMatrix(radialGradientBrush.Transform.Value) : null
+        },
         IConicGradientBrush conicGradientBrush => new SweepGradientPaintable(
             new VecD(conicGradientBrush.Center.Point.X, conicGradientBrush.Center.Point.Y),
             conicGradientBrush.Angle,
             conicGradientBrush.GradientStops.Select(stop =>
-                new GradientStop(new BackendColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A), stop.Offset))),
+                new GradientStop(new BackendColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A), stop.Offset)))
+        {
+            AbsoluteValues = conicGradientBrush.Center.Unit == RelativeUnit.Absolute,
+            Transform = conicGradientBrush.Transform != null ? ToDrawieMatrix(conicGradientBrush.Transform.Value) : null
+        },
         null => null,
 
     };
@@ -67,22 +84,25 @@ internal static class ColorHelpers
         ColorPaintable color => new SolidColorBrush(color.Color.ToColor()),
         LinearGradientPaintable linearGradientPaintable => new LinearGradientBrush
         {
-            StartPoint = new RelativePoint(linearGradientPaintable.Start.X, linearGradientPaintable.Start.Y, RelativeUnit.Absolute),
-            EndPoint = new RelativePoint(linearGradientPaintable.End.X, linearGradientPaintable.End.Y, RelativeUnit.Absolute),
-            GradientStops = ToAvaloniaGradientStops(linearGradientPaintable.GradientStops)
+            StartPoint = new RelativePoint(linearGradientPaintable.Start.X, linearGradientPaintable.Start.Y, paintable.AbsoluteValues ? RelativeUnit.Absolute : RelativeUnit.Relative),
+            EndPoint = new RelativePoint(linearGradientPaintable.End.X, linearGradientPaintable.End.Y, paintable.AbsoluteValues ? RelativeUnit.Absolute : RelativeUnit.Relative),
+            GradientStops = ToAvaloniaGradientStops(linearGradientPaintable.GradientStops),
+            Transform = linearGradientPaintable.Transform.HasValue ? new MatrixTransform(ToAvaloniaMatrix(linearGradientPaintable.Transform.Value)) : null
         },
         RadialGradientPaintable radialGradientPaintable => new RadialGradientBrush
         {
-            Center = new RelativePoint(radialGradientPaintable.Center.X, radialGradientPaintable.Center.Y, RelativeUnit.Absolute),
+            Center = new RelativePoint(radialGradientPaintable.Center.X, radialGradientPaintable.Center.Y, paintable.AbsoluteValues ? RelativeUnit.Absolute : RelativeUnit.Relative),
             RadiusX = new RelativeScalar(radialGradientPaintable.Radius, RelativeUnit.Absolute),
             RadiusY = new RelativeScalar(radialGradientPaintable.Radius, RelativeUnit.Absolute),
-            GradientStops = ToAvaloniaGradientStops(radialGradientPaintable.GradientStops)
+            GradientStops = ToAvaloniaGradientStops(radialGradientPaintable.GradientStops),
+            Transform = radialGradientPaintable.Transform.HasValue ? new MatrixTransform(ToAvaloniaMatrix(radialGradientPaintable.Transform.Value)) : null
         },
         SweepGradientPaintable conicGradientPaintable => new ConicGradientBrush
         {
             Angle = conicGradientPaintable.Angle,
-            Center = new RelativePoint(conicGradientPaintable.Center.X, conicGradientPaintable.Center.Y, RelativeUnit.Absolute),
-            GradientStops = ToAvaloniaGradientStops(conicGradientPaintable.GradientStops)
+            Center = new RelativePoint(conicGradientPaintable.Center.X, conicGradientPaintable.Center.Y, paintable.AbsoluteValues ? RelativeUnit.Absolute : RelativeUnit.Relative),
+            GradientStops = ToAvaloniaGradientStops(conicGradientPaintable.GradientStops),
+            Transform = conicGradientPaintable.Transform.HasValue ? new MatrixTransform(ToAvaloniaMatrix(conicGradientPaintable.Transform.Value)) : null
         },
         null => null,
         _ => throw new NotImplementedException()
@@ -98,4 +118,14 @@ internal static class ColorHelpers
 
         return stops;
     }
+
+    private static Matrix ToAvaloniaMatrix(Matrix3X3 matrix)
+    {
+        return new Matrix(matrix.ScaleX, matrix.SkewY, matrix.SkewX, matrix.ScaleY, matrix.TransX, matrix.TransY);
+    }
+
+    private static Matrix3X3 ToDrawieMatrix(Matrix matrix)
+    {
+        return matrix.ToSKMatrix().ToMatrix3X3();
+    }
 }

+ 1 - 1
src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs

@@ -454,7 +454,7 @@ internal class DocumentUpdater
 
             if (closestMember == null)
             {
-                closestMember = doc.NodeGraphHandler.StructureTree.Members.FirstOrDefault();
+                closestMember = doc.NodeGraphHandler.StructureTree.Members.FirstOrDefault(x => x.Id != info.Id);
             }
 
             if (closestMember != null)

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

@@ -180,6 +180,7 @@ internal class VectorPathToolExecutor : UpdateableChangeExecutor, IPathExecutorF
             VectorShapeChangeType changeType = name switch
             {
                 nameof(IFillableShapeToolbar.Fill) => VectorShapeChangeType.Fill,
+                nameof(IFillableShapeToolbar.FillBrush) => VectorShapeChangeType.Fill,
                 nameof(IShapeToolbar.StrokeBrush) => VectorShapeChangeType.Stroke,
                 nameof(IShapeToolbar.ToolSize) => VectorShapeChangeType.Stroke,
                 nameof(IShapeToolbar.AntiAliasing) => VectorShapeChangeType.OtherVisuals,

+ 1 - 0
src/PixiEditor/Models/IO/ExportConfig.cs

@@ -14,6 +14,7 @@ public class ExportConfig
    
    public VectorExportConfig? VectorExportConfig { get; set; }
    public string ExportOutput { get; set; }
+   public bool ExportFramesToFolder { get; set; }
 
    public ExportConfig(VecI exportSize)
    {

+ 54 - 0
src/PixiEditor/Models/IO/Exporter.cs

@@ -5,10 +5,12 @@ using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Platform.Storage;
 using ChunkyImageLib;
 using Drawie.Backend.Core;
+using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using PixiEditor.Helpers;
 using PixiEditor.Models.Files;
 using Drawie.Numerics;
+using PixiEditor.UI.Common.Localization;
 using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Models.IO;
@@ -114,6 +116,23 @@ internal class Exporter
         if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
             return new SaveResult(SaveResultType.InvalidPath);
 
+        if (exportConfig.ExportFramesToFolder)
+        {
+            try
+            {
+                await ExportFramesToFolderAsync(document, directory, exportConfig, job);
+                job?.Finish();
+                return new SaveResult(SaveResultType.Success);
+            }
+            catch (Exception e)
+            {
+                job?.Finish();
+                Console.WriteLine(e);
+                CrashHelper.SendExceptionInfo(e);
+                return new SaveResult(SaveResultType.UnknownError);
+            }
+        }
+
         var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithExtension));
 
         if (typeFromPath is null)
@@ -161,6 +180,41 @@ internal class Exporter
         }
     }
 
+    private static async Task ExportFramesToFolderAsync(DocumentViewModel document, string directory,
+        ExportConfig exportConfig, ExportJob? job)
+    {
+        if (!Directory.Exists(directory))
+        {
+            Directory.CreateDirectory(directory);
+        }
+
+        int totalFrames = document.AnimationDataViewModel.GetVisibleFramesCount();
+        document.RenderFramesProgressive(
+            (surface, frame) =>
+        {
+            job?.CancellationTokenSource.Token.ThrowIfCancellationRequested();
+            job?.Report(((double)frame / totalFrames),
+                new LocalizedString("RENDERING_FRAME", frame, totalFrames));
+            if (exportConfig.ExportSize != surface.Size)
+            {
+                var resized = surface.ResizeNearestNeighbor(exportConfig.ExportSize);
+                SaveAsPng(Path.Combine(directory, $"{frame}.png"), resized);
+            }
+            else
+            {
+                SaveAsPng(Path.Combine(directory, $"{frame}.png"), surface);
+            }
+
+        }, CancellationToken.None, exportConfig.ExportOutput);
+    }
+
+    public static void SaveAsPng(string path, Surface surface)
+    {
+        using var snapshot = surface.DrawingSurface.Snapshot();
+        using var fileStream = new FileStream(path, FileMode.Create);
+        snapshot.Encode(EncodedImageFormat.Png).SaveTo(fileStream);
+    }
+
     public static void SaveAsGZippedBytes(string path, Surface surface)
     {
         SaveAsGZippedBytes(path, surface, new RectI(VecI.Zero, surface.Size));

+ 10 - 1
src/PixiEditor/Models/Serialization/Factories/TextSerializationFactory.cs

@@ -39,6 +39,8 @@ internal class TextSerializationFactory : VectorShapeSerializationFactory<TextVe
         {
             builder.AddString(original.Path.ToSvgPathData());
         }
+
+        builder.AddVecD(original.PathOffset);
     }
 
     protected override bool DeserializeVectorData(ByteExtractor extractor, Matrix3X3 matrix, Paintable strokePaintable,
@@ -71,6 +73,12 @@ internal class TextSerializationFactory : VectorShapeSerializationFactory<TextVe
             path = VectorPath.FromSvgPath(DeserializeStringCompatible(extractor, serializerData));
         }
 
+        VecD pathOffset = VecD.Zero;
+        if (!IsFilePreVersion(serializerData, new Version(2, 0, 0, 95)))
+        {
+            pathOffset = extractor.GetVecD();
+        }
+
         FontFamilyName family =
             new FontFamilyName(fontFamily) { FontUri = isFontFromFile ? new Uri(fontPath, UriKind.Absolute) : null };
         Font font = Font.FromFontFamily(family);
@@ -107,7 +115,8 @@ internal class TextSerializationFactory : VectorShapeSerializationFactory<TextVe
             Path = path,
             MissingFontFamily = missingFamily,
             MissingFontText = new LocalizedString("MISSING_FONT"),
-            AntiAlias = antiAlias
+            AntiAlias = antiAlias,
+            PathOffset = pathOffset
         };
 
         return true;

+ 2 - 2
src/PixiEditor/Properties/AssemblyInfo.cs

@@ -43,5 +43,5 @@ using System.Runtime.InteropServices;
 // You can specify all the values or you can default the Build and Revision Numbers
 // by using the '*' as shown below:
 // [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("2.0.0.92")]
-[assembly: AssemblyFileVersion("2.0.0.92")]
+[assembly: AssemblyVersion("2.0.0.95")]
+[assembly: AssemblyFileVersion("2.0.0.95")]

+ 4 - 0
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -401,7 +401,11 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
                         SerializationUtil.DeserializeDict(serializedNode.AdditionalData, config, allFactories,
                             serializerData)));
                 }
+            }
 
+            foreach (var node in graph.AllNodes)
+            {
+                Guid nodeGuid = mappedNodeIds[node.Id];
                 if (node.InputConnections != null)
                 {
                     foreach (var connections in node.InputConnections)

+ 2 - 2
src/PixiEditor/Views/Animations/Timeline.cs

@@ -300,9 +300,9 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
     {
         if (dragged)
         {
-            if (draggedKeyFrames.Length > 0)
+            if (draggedKeyFrames is { Length: > 0 })
             {
-                ChangeKeyFramesLengthCommand.Execute((draggedKeyFrames.ToArray(), 0, true));
+                ChangeKeyFramesLengthCommand?.Execute((draggedKeyFrames.ToArray(), 0, true));
             }
         }
 

+ 5 - 2
src/PixiEditor/Views/Dialogs/ExportFileDialog.cs

@@ -7,6 +7,7 @@ using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.Files;
 using PixiEditor.Models.IO;
 using Drawie.Numerics;
+using PixiEditor.AnimationRenderer.Core;
 using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Views.Dialogs;
@@ -143,14 +144,16 @@ internal class ExportFileDialog : CustomDialog
             FilePath = popup.SavePath;
             ChosenFormat = popup.SaveFormat;
             ExportOutput = popup.ExportOutput;
-            
+
             ExportConfig.ExportSize = new VecI(FileWidth, FileHeight);
             ExportConfig.ExportOutput = ExportOutput.Name;
+            ExportConfig.ExportFramesToFolder = popup.FolderExport;
             ExportConfig.AnimationRenderer = ChosenFormat is VideoFileType ? new FFMpegRenderer()
             {
                 Size = new VecI(FileWidth, FileHeight),
                 OutputFormat = ChosenFormat.PrimaryExtension.Replace(".", ""),
-                FrameRate = document.AnimationDataViewModel.FrameRateBindable
+                FrameRate = document.AnimationDataViewModel.FrameRateBindable,
+                QualityPreset = (QualityPreset)popup.AnimationPresetIndex
             }
             : null;
             ExportConfig.ExportAsSpriteSheet = popup.IsSpriteSheetExport;

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

@@ -7,6 +7,7 @@
                          xmlns:indicators="clr-namespace:PixiEditor.Views.Indicators"
                          xmlns:input1="clr-namespace:PixiEditor.Views.Input"
                          xmlns:controls="clr-namespace:PixiEditor.UI.Common.Controls;assembly=PixiEditor.UI.Common"
+                         xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
                          CanResize="False"
                          CanMinimize="False"
                          SizeToContent="WidthAndHeight"
@@ -27,7 +28,26 @@
                 </TabControl.Styles>
                 <TabControl.Items>
                     <TabItem IsSelected="True" ui1:Translator.Key="EXPORT_IMAGE_HEADER" />
-                    <TabItem ui1:Translator.Key="EXPORT_ANIMATION_HEADER" />
+                    <TabItem ui1:Translator.Key="EXPORT_ANIMATION_HEADER">
+                        <StackPanel Orientation="Vertical" Spacing="5">
+                            <StackPanel Spacing="5" Orientation="Horizontal">
+                                <TextBlock ui1:Translator.Key="ANIMATION_QUALITY_PRESET" />
+                                <ComboBox
+                                    SelectedIndex="{Binding ElementName=saveFilePopup, Path=AnimationPresetIndex}"
+                                    ItemsSource="{Binding ElementName=saveFilePopup, Path=QualityPresetValues}">
+                                    <ComboBox.ItemTemplate>
+                                        <DataTemplate>
+                                            <TextBlock
+                                                ui1:Translator.Key="{Binding Converter={converters:EnumToLocalizedStringConverter}}" />
+                                        </DataTemplate>
+                                    </ComboBox.ItemTemplate>
+                                </ComboBox>
+
+                                <CheckBox IsChecked="{Binding ElementName=saveFilePopup, Path=FolderExport, Mode=TwoWay}"
+                                          ui1:Translator.Key="EXPORT_FRAMES" />
+                            </StackPanel>
+                        </StackPanel>
+                    </TabItem>
                     <TabItem ui1:Translator.Key="EXPORT_SPRITESHEET_HEADER">
                         <Grid>
                             <Grid.ColumnDefinitions>

+ 73 - 24
src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -13,6 +13,7 @@ using PixiEditor.Helpers;
 using PixiEditor.Models.Files;
 using PixiEditor.Models.IO;
 using Drawie.Numerics;
+using PixiEditor.AnimationRenderer.Core;
 using PixiEditor.UI.Common.Localization;
 using PixiEditor.ViewModels.Document;
 using Image = Drawie.Backend.Core.Surfaces.ImageData.Image;
@@ -76,6 +77,14 @@ internal partial class ExportFilePopup : PixiEditorPopup
     public static readonly StyledProperty<string> SizeHintProperty = AvaloniaProperty.Register<ExportFilePopup, string>(
         nameof(SizeHint));
 
+    public static readonly StyledProperty<bool> FolderExportProperty = AvaloniaProperty.Register<ExportFilePopup, bool>(
+        nameof(FolderExport));
+
+    public bool FolderExport
+    {
+        get => GetValue(FolderExportProperty);
+        set => SetValue(FolderExportProperty, value);
+    }
     public string SizeHint
     {
         get => GetValue(SizeHintProperty);
@@ -171,6 +180,14 @@ internal partial class ExportFilePopup : PixiEditorPopup
 
     public bool IsSpriteSheetExport => SelectedExportIndex == 2;
 
+    public int AnimationPresetIndex
+    {
+        get { return (int)GetValue(AnimationPresetIndexProperty); }
+        set { SetValue(AnimationPresetIndexProperty, value); }
+    }
+
+    public Array QualityPresetValues { get; }
+
     private DocumentViewModel document;
     private Image[]? videoPreviewFrames = [];
     private DispatcherTimer videoPreviewTimer = new DispatcherTimer();
@@ -179,6 +196,9 @@ internal partial class ExportFilePopup : PixiEditorPopup
 
     private Task? generateSpriteSheetTask;
 
+    public static readonly StyledProperty<int> AnimationPresetIndexProperty
+        = AvaloniaProperty.Register<ExportFilePopup, int>("AnimationPresetIndex", 4);
+
     static ExportFilePopup()
     {
         SaveWidthProperty.Changed.Subscribe(RerenderPreview);
@@ -193,8 +213,10 @@ internal partial class ExportFilePopup : PixiEditorPopup
     {
         SaveWidth = imageWidth;
         SaveHeight = imageHeight;
+        QualityPresetValues = Enum.GetValues(typeof(QualityPreset));
 
         InitializeComponent();
+
         DataContext = this;
         Loaded += (_, _) => sizePicker.FocusWidthPicker();
 
@@ -467,37 +489,64 @@ internal partial class ExportFilePopup : PixiEditorPopup
     /// </summary>
     private async Task<string?> ChoosePath()
     {
-        FilePickerSaveOptions options = new FilePickerSaveOptions
-        {
-            Title = new LocalizedString("EXPORT_SAVE_TITLE"),
-            SuggestedFileName = SuggestedName,
-            SuggestedStartLocation = string.IsNullOrEmpty(document.FullFilePath)
-                ? await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents)
-                : await GetTopLevel(this).StorageProvider.TryGetFolderFromPathAsync(document.FullFilePath),
-            FileTypeChoices =
-                SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1
-                    ? FileTypeDialogDataSet.SetKind.Video
-                    : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector),
-            ShowOverwritePrompt = true
-        };
+        bool folderExport = FolderExport && SelectedExportIndex == 1;
 
-        IStorageFile file = await GetTopLevel(this).StorageProvider.SaveFilePickerAsync(options);
-        if (file != null)
+        if (folderExport)
         {
-            if (string.IsNullOrEmpty(file.Name) == false)
+            FolderPickerOpenOptions options = new FolderPickerOpenOptions()
             {
-                SaveFormat = SupportedFilesHelper.GetSaveFileType(
-                    SelectedExportIndex == 1
-                        ? FileTypeDialogDataSet.SetKind.Video
-                        : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector, file);
-                if (SaveFormat == null)
+                Title = new LocalizedString("EXPORT_SAVE_TITLE"),
+                SuggestedStartLocation = string.IsNullOrEmpty(document.FullFilePath)
+                    ? await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents)
+                    : await GetTopLevel(this).StorageProvider.TryGetFolderFromPathAsync(document.FullFilePath),
+                AllowMultiple = false,
+            };
+
+            var folders = await GetTopLevel(this).StorageProvider.OpenFolderPickerAsync(options);
+            if (folders.Count > 0)
+            {
+                IStorageFolder folder = folders[0];
+                if (folder != null)
                 {
-                    return null;
+                    SavePath = folder.Path.LocalPath;
+                    return SavePath;
                 }
+            }
+        }
+        else
+        {
+            FilePickerSaveOptions options = new FilePickerSaveOptions
+            {
+                Title = new LocalizedString("EXPORT_SAVE_TITLE"),
+                SuggestedFileName = SuggestedName,
+                SuggestedStartLocation = string.IsNullOrEmpty(document.FullFilePath)
+                    ? await GetTopLevel(this).StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents)
+                    : await GetTopLevel(this).StorageProvider.TryGetFolderFromPathAsync(document.FullFilePath),
+                FileTypeChoices =
+                    SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1
+                        ? FileTypeDialogDataSet.SetKind.Video
+                        : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector),
+                ShowOverwritePrompt = true
+            };
 
-                string fileName = SupportedFilesHelper.FixFileExtension(file.Path.LocalPath, SaveFormat);
+            IStorageFile file = await GetTopLevel(this).StorageProvider.SaveFilePickerAsync(options);
+            if (file != null)
+            {
+                if (string.IsNullOrEmpty(file.Name) == false)
+                {
+                    SaveFormat = SupportedFilesHelper.GetSaveFileType(
+                        SelectedExportIndex == 1
+                            ? FileTypeDialogDataSet.SetKind.Video
+                            : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector, file);
+                    if (SaveFormat == null)
+                    {
+                        return null;
+                    }
+
+                    string fileName = SupportedFilesHelper.FixFileExtension(file.Path.LocalPath, SaveFormat);
 
-                return fileName;
+                    return fileName;
+                }
             }
         }
 

+ 8 - 2
src/PixiEditor/Views/Nodes/Properties/DoublePropertyView.axaml

@@ -43,10 +43,16 @@
                                    Min="{Binding Min}" Max="{Binding Max}"
                                    Value="{Binding DoubleValue, Mode=TwoWay}" />
 
-                <Slider Value="{Binding DoubleValue, Mode=TwoWay}"
-                        Margin="5, 0"
+                <Slider
+                        Margin="5, 0" Name="slider"
                         Classes.colorSlider="{Binding SliderSettings.IsColorSlider}"
                         Minimum="{Binding Min}" Maximum="{Binding Max}">
+                    <Interaction.Behaviors>
+                        <behaviours:SliderBindingBehavior
+                            CanBind="{Binding NumberPickerMode,
+                                            Converter={converters:EnumBooleanConverter}, ConverterParameter=Slider}"
+                            ValueBinding="{Binding DoubleValue, Mode=TwoWay}"/>
+                    </Interaction.Behaviors>
                     <Slider.Styles>
                         <Style Selector="Slider.colorSlider Border#TrackBackground">
                             <Setter Property="Background" Value="{Binding SliderSettings.BackgroundBrush}" />

+ 10 - 0
src/PixiEditor/Views/Overlays/PathOverlay/VectorPathOverlay.cs

@@ -499,6 +499,7 @@ public class VectorPathOverlay : Overlay
 
     private void SelectAnchor(AnchorHandle handle, bool append = false)
     {
+        lastSelectedIndices.Clear();
         if (append)
         {
             handle.IsSelected = !handle.IsSelected;
@@ -888,6 +889,15 @@ public class VectorPathOverlay : Overlay
         }
 
         anchorHandles.Clear();
+        foreach (var handle in controlPointHandles)
+        {
+            handle.OnPress -= OnControlPointPress;
+            handle.OnDrag -= OnControlPointDrag;
+            handle.OnRelease -= OnHandleRelease;
+            Handles.Remove(handle);
+        }
+
+        controlPointHandles.Clear();
     }
 
     private VecD TryFindAnySnap(VecD delta, VectorPath path, out string? axisX, out string? axisY, out VecD? snapPoint)