فهرست منبع

Added preview and copying folders

flabbet 7 ماه پیش
والد
کامیت
b0c15ae37b

+ 344 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/PreviewTransformSelected_UpdateableChange.cs

@@ -0,0 +1,344 @@
+using ChunkyImageLib.Operations;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.ChangeInfos.Objects;
+using PixiEditor.ChangeableDocument.Changes.Selection;
+using Drawie.Backend.Core;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+
+internal class PreviewTransformSelected_UpdateableChange : InterruptableUpdateableChange
+{
+    private readonly bool drawOnMask;
+    private ShapeCorners masterCorners;
+
+    private List<MemberTransformationData> memberData;
+
+    private VectorPath? originalPath;
+    private RectD originalSelectionBounds;
+    private VecD selectionAwareSize;
+    private VecD tightBoundsSize;
+    private RectD cornersToSelectionOffset;
+    private VecD originalCornersSize;
+
+    private bool isTransformingSelection;
+    private bool hasEnqueudImages = false;
+    private int frame;
+    private bool appliedOnce;
+    private AffectedArea lastAffectedArea;
+
+    private static Paint RegularPaint { get; } = new() { BlendMode = BlendMode.SrcOver };
+
+    [GenerateUpdateableChangeActions]
+    public PreviewTransformSelected_UpdateableChange(
+        ShapeCorners masterCorners,
+        Dictionary<Guid, ShapeCorners> memberCorners,
+        bool transformMask,
+        int frame)
+    {
+        memberData = new();
+        foreach (var corners in memberCorners)
+        {
+            memberData.Add(new MemberTransformationData(corners.Key) { MemberCorners = corners.Value });
+        }
+
+        this.masterCorners = masterCorners;
+        this.drawOnMask = transformMask;
+        this.frame = frame;
+    }
+
+    public override bool InitializeAndValidate(Document target)
+    {
+        if (memberData.Count == 0)
+            return false;
+
+        originalCornersSize = masterCorners.RectSize;
+        RectD tightBoundsWithSelection = default;
+        bool hasSelection = target.Selection.SelectionPath is { IsEmpty: false };
+
+        if (hasSelection)
+        {
+            originalPath = new VectorPath(target.Selection.SelectionPath) { FillType = PathFillType.EvenOdd };
+            tightBoundsWithSelection = originalPath.TightBounds;
+            originalSelectionBounds = tightBoundsWithSelection;
+            selectionAwareSize = tightBoundsWithSelection.Size;
+            isTransformingSelection = true;
+
+            tightBoundsSize = tightBoundsWithSelection.Size;
+            cornersToSelectionOffset = new RectD(masterCorners.TopLeft - tightBoundsWithSelection.TopLeft,
+                tightBoundsSize - masterCorners.RectSize);
+        }
+
+        StructureNode firstLayer = target.FindMemberOrThrow(memberData[0].MemberId);
+        RectD tightBounds = firstLayer.GetTightBounds(frame) ?? default;
+
+        if (memberData.Count == 1 && firstLayer is VectorLayerNode vectorLayer)
+        {
+            tightBounds = vectorLayer.ShapeData?.GeometryAABB ?? default;
+        }
+
+        for (var i = 1; i < memberData.Count; i++)
+        {
+            StructureNode layer = target.FindMemberOrThrow(memberData[i].MemberId);
+
+            var layerTightBounds = layer.GetTightBounds(frame);
+
+            if (tightBounds == default)
+            {
+                tightBounds = layerTightBounds.GetValueOrDefault();
+            }
+
+            if (layerTightBounds is not null)
+            {
+                tightBounds = tightBounds.Union(layerTightBounds.Value);
+            }
+        }
+
+        if (tightBounds == default)
+            return false;
+
+        tightBoundsSize = tightBounds.Size;
+
+        foreach (var member in memberData)
+        {
+            StructureNode layer = target.FindMemberOrThrow(member.MemberId);
+
+            if (layer is IReadOnlyImageNode)
+            {
+                var targetBounds = tightBoundsWithSelection != default ? tightBoundsWithSelection : tightBounds;
+                SetImageMember(target, member, targetBounds, layer);
+            }
+            else if (layer is ITransformableObject transformable)
+            {
+                SetTransformableMember(layer, member, transformable, tightBounds);
+            }
+        }
+
+        return true;
+    }
+
+    private void SetTransformableMember(StructureNode layer, 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);
+    }
+
+    private void SetImageMember(Document target, MemberTransformationData member, RectD originalTightBounds,
+        StructureNode layer)
+    {
+        ChunkyImage image =
+            DrawingChangeHelper.GetTargetImageOrThrow(target, member.MemberId, drawOnMask, frame);
+        VectorPath? pathToExtract = originalPath;
+        RectD targetBounds = originalTightBounds;
+
+        if (pathToExtract == null)
+        {
+            RectD tightBounds = layer.GetTightBounds(frame).GetValueOrDefault();
+            pathToExtract = new VectorPath();
+            pathToExtract.AddRect(tightBounds.RoundOutwards());
+        }
+
+        member.OriginalPath = pathToExtract;
+        member.OriginalBounds = targetBounds;
+        var extracted = ExtractArea(image, pathToExtract, member.RoundedOriginalBounds.Value);
+        if (extracted.IsT0)
+            return;
+
+        member.AddImage(extracted.AsT1.image, extracted.AsT1.extractedRect.Pos);
+    }
+
+    [UpdateChangeMethod]
+    public void Update(ShapeCorners masterCorners)
+    {
+        this.masterCorners = masterCorners;
+
+        var globalMatrixWithSelection = OperationHelper.CreateMatrixFromPoints(masterCorners, originalCornersSize);
+        var tightBoundsGlobalMatrix = OperationHelper.CreateMatrixFromPoints(masterCorners, tightBoundsSize);
+
+        foreach (var member in memberData)
+        {
+            Matrix3X3 localMatrix = tightBoundsGlobalMatrix;
+
+            if (member.IsImage)
+            {
+                localMatrix =
+                    Matrix3X3.CreateTranslation(
+                            (float)-cornersToSelectionOffset.TopLeft.X, (float)-cornersToSelectionOffset.TopLeft.Y)
+                        .PostConcat(
+                            Matrix3X3.CreateTranslation(
+                                (float)member.OriginalPos.Value.X - (float)member.OriginalBounds.Value.Left,
+                                (float)member.OriginalPos.Value.Y - (float)member.OriginalBounds.Value.Top));
+
+                localMatrix = localMatrix.PostConcat(selectionAwareSize.Length > 0
+                    ? globalMatrixWithSelection
+                    : tightBoundsGlobalMatrix);
+            }
+            else if (member.OriginalMatrix is not null)
+            {
+                if (memberData.Count > 1)
+                {
+                    localMatrix = member.OriginalMatrix.Value;
+                    localMatrix = localMatrix.PostConcat(Matrix3X3.CreateTranslation(
+                            (float)member.OriginalPos.Value.X - (float)member.OriginalBounds.Value.Left,
+                            (float)member.OriginalPos.Value.Y - (float)member.OriginalBounds.Value.Top))
+                        .PostConcat(tightBoundsGlobalMatrix);
+                }
+                else
+                {
+                    localMatrix = Matrix3X3.CreateTranslation(
+                        (float)-member.OriginalBounds.Value.X,
+                        (float)-member.OriginalBounds.Value.Y);
+                    localMatrix = localMatrix.PostConcat(tightBoundsGlobalMatrix);
+                }
+            }
+
+            member.LocalMatrix = localMatrix;
+        }
+    }
+
+    public OneOf<None, (Surface image, RectI extractedRect)> ExtractArea(ChunkyImage image, VectorPath path,
+        RectI pathBounds)
+    {
+        // get rid of transparent areas on edges
+        var memberImageBounds = image.FindChunkAlignedMostUpToDateBounds();
+        if (memberImageBounds is null)
+            return new None();
+        pathBounds = pathBounds.Intersect(memberImageBounds.Value);
+        pathBounds = pathBounds.Intersect(new RectI(VecI.Zero, image.LatestSize));
+        if (pathBounds.IsZeroOrNegativeArea)
+            return new None();
+
+        // shift the clip to account for the image being smaller than the selection
+        VectorPath clipPath = new VectorPath(path) { FillType = PathFillType.EvenOdd };
+        clipPath.Transform(Matrix3X3.CreateTranslation(-pathBounds.X, -pathBounds.Y));
+
+        // draw
+        Surface output = new(pathBounds.Size);
+        output.DrawingSurface.Canvas.Save();
+        output.DrawingSurface.Canvas.ClipPath(clipPath);
+        image.DrawMostUpToDateRegionOn(pathBounds, ChunkResolution.Full, output.DrawingSurface, VecI.Zero);
+        output.DrawingSurface.Canvas.Restore();
+
+        return (output, pathBounds);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply,
+        out bool ignoreInUndo)
+    {
+        foreach (var data in memberData)
+        {
+            if (data.IsImage)
+            {
+                ChunkyImage targetImage =
+                    DrawingChangeHelper.GetTargetImageOrThrow(target, data.MemberId, drawOnMask, frame);
+                targetImage.CancelChanges();
+            }
+        }
+        
+        hasEnqueudImages = false;
+        ignoreInUndo = true;
+        return new None();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        List<IChangeInfo> infos = new();
+
+        foreach (var member in memberData)
+        {
+            if (member.IsImage)
+            {
+                ChunkyImage targetImage =
+                    DrawingChangeHelper.GetTargetImageOrThrow(target, member.MemberId, drawOnMask, frame);
+
+                infos.Add(DrawingChangeHelper.CreateAreaChangeInfo(member.MemberId,
+                        DrawImage(member, targetImage), drawOnMask)
+                    .AsT1);
+            }
+            else if (member.IsTransformable)
+            {
+                member.TransformableObject.TransformationMatrix = member.LocalMatrix;
+
+                AffectedArea translationAffectedArea = GetTranslationAffectedArea();
+                var tmp = new AffectedArea(translationAffectedArea);
+                if (lastAffectedArea.Chunks != null)
+                {
+                    translationAffectedArea.UnionWith(lastAffectedArea);
+                }
+
+                lastAffectedArea = tmp;
+                infos.Add(new TransformObject_ChangeInfo(member.MemberId, translationAffectedArea));
+            }
+        }
+
+        if (isTransformingSelection)
+        {
+            infos.Add(SelectionChangeHelper.DoSelectionTransform(target, originalPath!, originalSelectionBounds,
+                masterCorners, cornersToSelectionOffset, originalCornersSize));
+        }
+
+        return infos;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        return new None();
+    }
+
+    public override void Dispose()
+    {
+        if (hasEnqueudImages)
+            throw new InvalidOperationException(
+                "Attempted to dispose the change while it's internally stored image is still used enqueued in some ChunkyImage. Most likely someone tried to dispose a change after ApplyTemporarily was called but before the subsequent call to Apply. Don't do that.");
+
+        foreach (var member in memberData)
+        {
+            member.Dispose();
+        }
+    }
+
+    private AffectedArea GetTranslationAffectedArea()
+    {
+        RectI oldBounds = (RectI)masterCorners.AABBBounds.RoundOutwards();
+
+        HashSet<VecI> chunks = new();
+        VecI topLeftChunk = new VecI((int)oldBounds.Left / ChunkyImage.FullChunkSize,
+            (int)oldBounds.Top / ChunkyImage.FullChunkSize);
+        VecI bottomRightChunk = new VecI((int)oldBounds.Right / ChunkyImage.FullChunkSize,
+            (int)oldBounds.Bottom / ChunkyImage.FullChunkSize);
+
+        for (int x = topLeftChunk.X; x <= bottomRightChunk.X; x++)
+        {
+            for (int y = topLeftChunk.Y; y <= bottomRightChunk.Y; y++)
+            {
+                chunks.Add(new VecI(x, y));
+            }
+        }
+
+        var final = new AffectedArea(chunks);
+        return final;
+    }
+
+    private AffectedArea DrawImage(MemberTransformationData data, ChunkyImage memberImage)
+    {
+        var prevAffArea = memberImage.FindAffectedArea();
+
+        memberImage.CancelChanges();
+
+        memberImage.EnqueueDrawImage(data.LocalMatrix, data.Image, RegularPaint, false);
+        hasEnqueudImages = true;
+
+        var affectedArea = memberImage.FindAffectedArea();
+        affectedArea.UnionWith(prevAffArea);
+        return affectedArea;
+    }
+}

+ 19 - 6
src/PixiEditor.ChangeableDocument/Changes/Structure/DuplicateFolder_Change.cs

@@ -1,4 +1,6 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph;
+using System.Collections.Immutable;
+using System.Collections.ObjectModel;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
@@ -13,14 +15,17 @@ internal class DuplicateFolder_Change : Change
     private Guid[] contentGuids;
     private Guid[] contentDuplicateGuids;
 
+    private Guid[]? childGuidsToUse;
+
     private ConnectionsData? connectionsData;
     private Dictionary<Guid, ConnectionsData> contentConnectionsData = new();
 
     [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)
@@ -57,12 +62,12 @@ internal class DuplicateFolder_Change : Change
         List<IChangeInfo> operations = new();
 
         target.NodeGraph.AddNode(clone);
-        
+
         operations.Add(CreateNode_ChangeInfo.CreateFromNode(clone));
         operations.AddRange(NodeOperations.AppendMember(targetInput, clone.Output, clone.Background, clone.Id));
 
         DuplicateContent(target, clone, existingLayer, operations);
-        
+
         ignoreInUndo = false;
 
         return operations;
@@ -87,7 +92,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();
             }
@@ -109,6 +114,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 +123,13 @@ internal class DuplicateFolder_Change : Change
                 return false;
 
             Node? node = targetNode.Clone();
+            
+            if(childGuidsToUse is not null && counter < childGuidsToUse.Length)
+            {
+                node.Id = childGuidsToUse[counter];
+                counter++;
+            }
+            
             nodeMap[x.Id] = node.Id;
             contentGuidList.Add(node.Id);
 
@@ -133,7 +146,7 @@ internal class DuplicateFolder_Change : Change
             operations.AddRange(NodeOperations.ConnectStructureNodeProperties(updatedData,
                 target.FindNodeOrThrow<Node>(targetNodeId), target.NodeGraph));
         }
-        
+
         contentDuplicateGuids = contentGuidList.ToArray();
     }
 }

+ 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)

+ 1 - 1
src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -216,7 +216,7 @@ internal class DocumentOperationsModule : IDocumentOperations
         {
             Guid newGuid = Guid.NewGuid();
             Internals.ActionAccumulator.AddFinishedActions(
-                new DuplicateFolder_Action(guidValue, newGuid),
+                new DuplicateFolder_Action(guidValue, newGuid, null),
                 new SetSelectedMember_PassthroughAction(newGuid));
         }
     }

+ 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;

+ 44 - 12
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedExecutor.cs

@@ -1,4 +1,5 @@
 using System.Collections.Generic;
+using System.Collections.Immutable;
 using System.Linq;
 using Avalonia.Input;
 using ChunkyImageLib.DataHolders;
@@ -12,6 +13,7 @@ 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.Document.Nodes;
@@ -29,6 +31,7 @@ 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();
@@ -48,6 +51,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();
 
@@ -224,6 +228,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         if (isInProgress)
         {
             internals.ActionAccumulator.AddActions(new EndTransformSelected_Action());
+            internals!.ActionAccumulator.AddActions(new EndPreviewTransformSelected_Action());
             document!.TransformHandler.HideTransform();
             AddSnappingForMembers(selectedMembers);
 
@@ -252,8 +257,12 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
             {
                 cornersOnStartDuplicate = corners;
                 duplicateOnStop = true;
+                internals.ActionAccumulator.AddActions(new EndTransformSelected_Action());
             }
 
+            internals.ActionAccumulator.AddActions(new PreviewTransformSelected_Action(corners, memberCorners,
+                false,
+                document!.AnimationHandler.ActiveFrameBindable));
             return;
         }
 
@@ -275,7 +284,8 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
     {
         List<IAction> actions = new();
 
-        List<Guid> newGuids = new();
+        List<Guid> newLayerGuids = new();
+        List<Guid> newGuidsOfOriginal = new();
 
         internals.ActionAccumulator.StartChangeBlock();
 
@@ -291,17 +301,35 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
             actions.Add(new SetSelection_Action(inverse.Op(selection, VectorPathOp.Difference)));
         }
 
-        for (var i = 0; i < selectedMembers.Count; i++)
+        for (var i = 0; i < originalSelectedMembers.Count; i++)
         {
-            var member = selectedMembers[i];
+            var member = originalSelectedMembers[i];
             Guid newGuid = Guid.NewGuid();
-            newGuids.Add(newGuid);
-            actions.Add(new DuplicateLayer_Action(member, newGuid));
-            if (document.SelectionPathBindable is { IsEmpty: false })
+            if (document.StructureHelper.Find(member) is not FolderNodeViewModel folder)
             {
-                actions.Add(new ClearSelectedArea_Action(newGuid, false,
-                    document.AnimationHandler.ActiveFrameBindable));
+                newLayerGuids.Add(newGuid);
+                actions.Add(new DuplicateLayer_Action(member, newGuid));
+                if (document.SelectionPathBindable is { IsEmpty: false })
+                {
+                    actions.Add(new ClearSelectedArea_Action(newGuid, 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()));
+
+                newLayerGuids.AddRange(newGuidsArray);
             }
+
+            newGuidsOfOriginal.Add(newGuid);
         }
 
         if (original != null)
@@ -314,20 +342,22 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         actions.Clear();
 
         actions.Add(new ClearSoftSelectedMembers_PassthroughAction());
-        foreach (var newGuid in newGuids)
+        foreach (var newGuid in newGuidsOfOriginal)
         {
             actions.Add(new AddSoftSelectedMember_PassthroughAction(newGuid));
         }
+        
+        actions.Add(new SetSelectedMember_PassthroughAction(newGuidsOfOriginal.Last()));
 
         internals!.ActionAccumulator.AddFinishedActions(actions.ToArray());
 
         actions.Clear();
 
         Dictionary<Guid, ShapeCorners> newMemberCorners = new();
-        for (var i = 0; i < selectedMembers.Count; i++)
+        for (var i = 0; i < memberCorners.Count; i++)
         {
-            var member = selectedMembers[i];
-            newMemberCorners.Add(newGuids[i], memberCorners[member]);
+            var member = memberCorners.ElementAt(i);
+            newMemberCorners.Add(newLayerGuids[i], member.Value);
         }
 
         actions.Add(new TransformSelected_Action(cornersOnStartDuplicate, false, newMemberCorners,
@@ -361,6 +391,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         }
 
         internals!.ActionAccumulator.AddActions(new EndTransformSelected_Action());
+        internals!.ActionAccumulator.AddActions(new EndPreviewTransformSelected_Action());
         internals!.ActionAccumulator.AddFinishedActions();
         document!.TransformHandler.HideTransform();
         AddSnappingForMembers(memberCorners.Keys.ToList());
@@ -383,6 +414,7 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         }
 
         internals!.ActionAccumulator.AddActions(new EndTransformSelected_Action());
+        internals!.ActionAccumulator.AddActions(new EndPreviewTransformSelected_Action());
         internals!.ActionAccumulator.AddFinishedActions();
         document!.TransformHandler.HideTransform();
         AddSnappingForMembers(memberCorners.Keys.ToList());

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

@@ -58,7 +58,7 @@ internal interface IDocument : IHandler
     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 HashSet<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false);
     public void UpdateSavedState();
 
     internal void InternalRaiseLayersChanged(LayersChangedEventArgs e);

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

@@ -156,7 +156,7 @@ public class PreviewPainter
                             return;
                         }
                         
-                        if (renderTexture != null && !renderTexture.IsDisposed)
+                        if (renderTexture is { IsDisposed: false })
                         {
                             renderTexture.DrawingSurface.Canvas.Restore();
                         }

+ 12 - 5
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -548,7 +548,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
         for (int i = 0; i < selectedLayers.Count; i++)
         {
-            var memberVm = StructureHelper.Find(selectedLayers[i]);
+            var memberVm = StructureHelper.Find(selectedLayers.ElementAt(i));
             IReadOnlyStructureNode? layer = Internals.Tracker.Document.FindMember(memberVm.Id);
             if (layer is null)
                 return new Error();
@@ -815,8 +815,15 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         List<Guid> layerGuids = new List<Guid>();
         if (SelectedStructureMember is not null)
             layerGuids.Add(SelectedStructureMember.Id);
+        
+        foreach (var member in softSelectedStructureMembers)
+        {
+            if (member.Id != SelectedStructureMember?.Id)
+            {
+                layerGuids.Add(member.Id);
+            }
+        }
 
-        layerGuids.AddRange(softSelectedStructureMembers.Select(x => x.Id));
         return layerGuids;
     }
 
@@ -825,9 +832,9 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     /// </summary>
     /// <param name="includeFoldersWithMask">Should folders with mask be included</param>
     /// <returns>A list of GUIDs of selected layers</returns>
-    public List<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false)
+    public HashSet<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false)
     {
-        var result = new List<Guid>();
+        var result = new HashSet<Guid>();
         List<Guid> selectedMembers = GetSelectedMembers();
         var allLayers = StructureHelper.GetAllMembers();
         foreach (var member in allLayers)
@@ -856,7 +863,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         OnPropertyChanged(nameof(AllChangesSaved));
     }
 
-    private void ExtractSelectedLayers(IFolderHandler folder, List<Guid> list,
+    private void ExtractSelectedLayers(IFolderHandler folder, HashSet<Guid> list,
         bool includeFoldersWithMask)
     {
         foreach (var member in folder.Children)

+ 18 - 0
src/PixiEditor/ViewModels/Document/Nodes/FolderNodeViewModel.cs

@@ -10,4 +10,22 @@ namespace PixiEditor.ViewModels.Document.Nodes;
 internal class FolderNodeViewModel : StructureMemberViewModel<FolderNode>, IFolderHandler
 {
     public ObservableCollection<IStructureMemberHandler> Children { get; } = new();
+
+    public int CountChildrenRecursive()
+    {
+        int count = 0;
+        foreach (var child in Children)
+        {
+            if (child is FolderNodeViewModel folder)
+            {
+                count += folder.CountChildrenRecursive();
+            }
+            else
+            {
+                count++;
+            }
+        }
+
+        return count;
+    }
 }