Browse Source

Move tool, various changes

Equbuxu 3 years ago
parent
commit
c3aa0ea3d4
37 changed files with 770 additions and 109 deletions
  1. 66 12
      src/ChunkyImageLib/ChunkyImage.cs
  2. 1 0
      src/ChunkyImageLib/IReadOnlyChunkyImage.cs
  3. 53 0
      src/ChunkyImageLib/Operations/ClearPathOperation.cs
  4. 1 1
      src/ChunkyImageLib/Operations/ClearRegionOperation.cs
  5. 24 0
      src/ChunkyImageLib/Operations/ImageOperation.cs
  6. 2 0
      src/Custom.ruleset
  7. 1 2
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelectedArea_Change.cs
  8. 7 3
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ShiftLayer_UpdateableChange.cs
  9. 183 0
      src/PixiEditor.ChangeableDocument/Changes/Drawing/TransformSelectedArea_UpdateableChange.cs
  10. 22 0
      src/PixiEditor.ChangeableDocument/Changes/Selection/SelectionChangeHelper.cs
  11. 34 0
      src/PixiEditor.ChangeableDocument/Changes/Selection/SetSelection_Change.cs
  12. 5 22
      src/PixiEditor.ChangeableDocument/Changes/Selection/TransformSelectionPath_UpdateableChange.cs
  13. 15 11
      src/PixiEditor/Models/Controllers/ClipboardController.cs
  14. 19 8
      src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs
  15. 22 10
      src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs
  16. 25 10
      src/PixiEditor/Models/DocumentModels/Public/DocumentToolsModule.cs
  17. 1 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/EllipseToolExecutor.cs
  18. 1 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/LineToolExecutor.cs
  19. 2 7
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs
  20. 2 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/RectangleToolExecutor.cs
  21. 1 1
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/ShapeToolExecutor.cs
  22. 48 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/ShiftLayerExecutor.cs
  23. 62 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedAreaExecutor.cs
  24. 1 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs
  25. 7 0
      src/PixiEditor/Models/Enums/ExecutorType.cs
  26. 10 2
      src/PixiEditor/ViewModels/SubViewModels/Document/DocumentTransformViewModel.cs
  27. 30 7
      src/PixiEditor/ViewModels/SubViewModels/Document/DocumentViewModel.cs
  28. 6 2
      src/PixiEditor/ViewModels/SubViewModels/Main/SelectionViewModel.cs
  29. 2 0
      src/PixiEditor/ViewModels/SubViewModels/Main/ToolsViewModel.cs
  30. 5 0
      src/PixiEditor/ViewModels/SubViewModels/Tools/ShapeTool.cs
  31. 2 0
      src/PixiEditor/ViewModels/SubViewModels/Tools/Tool.cs
  32. 12 0
      src/PixiEditor/ViewModels/SubViewModels/Tools/ToolSettings/Toolbars/MoveToolToolbar.cs
  33. 15 5
      src/PixiEditor/ViewModels/SubViewModels/Tools/Tools/MoveToolViewModel.cs
  34. 15 2
      src/PixiEditor/Views/UserControls/SelectionOverlay.cs
  35. 61 3
      src/PixiEditor/Views/UserControls/TransformOverlay/TransformOverlay.cs
  36. 6 0
      src/PixiEditor/Views/UserControls/Viewport.xaml
  37. 1 1
      src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs

+ 66 - 12
src/ChunkyImageLib/ChunkyImage.cs

@@ -90,18 +90,50 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         LatestSize = size;
         committedChunks = new()
         {
-            [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new(),
+            [ChunkResolution.Full] = new(),
+            [ChunkResolution.Half] = new(),
+            [ChunkResolution.Quarter] = new(),
+            [ChunkResolution.Eighth] = new(),
         };
         latestChunks = new()
         {
-            [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new(),
+            [ChunkResolution.Full] = new(),
+            [ChunkResolution.Half] = new(),
+            [ChunkResolution.Quarter] = new(),
+            [ChunkResolution.Eighth] = new(),
         };
         latestChunksData = new()
         {
-            [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new(),
+            [ChunkResolution.Full] = new(),
+            [ChunkResolution.Half] = new(),
+            [ChunkResolution.Quarter] = new(),
+            [ChunkResolution.Eighth] = new(),
         };
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public RectI? FindLatestBounds()
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            RectI? rect = null;
+            foreach (var (pos, _) in committedChunks[ChunkResolution.Full])
+            {
+                RectI chunkBounds = new RectI(pos * FullChunkSize, new VecI(FullChunkSize));
+                rect ??= chunkBounds;
+                rect = rect.Value.Union(chunkBounds);
+            }
+            foreach (var (pos, _) in latestChunks[ChunkResolution.Full])
+            {
+                RectI chunkBounds = new RectI(pos * FullChunkSize, new VecI(FullChunkSize));
+                rect ??= chunkBounds;
+                rect = rect.Value.Union(chunkBounds);
+            }
+            return rect;
+        }
+    }
+
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public ChunkyImage CloneFromCommitted()
     {
@@ -138,7 +170,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             };
         }
     }
-    
+
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public SKColor GetMostUpToDatePixel(VecI posOnImage)
     {
@@ -174,11 +206,11 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             {
                 Chunk? committedChunk = MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full);
                 Chunk? latestChunk = GetLatestChunk(chunkPos, ChunkResolution.Full);
-                SKColor committedColor = committedChunk is null ? 
-                    SKColors.Transparent :  
+                SKColor committedColor = committedChunk is null ?
+                    SKColors.Transparent :
                     committedChunk.Surface.GetSRGBPixel(posInChunk);
                 SKColor latestColor = latestChunk is null ?
-                    SKColors.Transparent : 
+                    SKColors.Transparent :
                     latestChunk.Surface.GetSRGBPixel(posInChunk);
                 // using a whole chunk just to draw 1 pixel is kinda dumb,
                 // but this should be faster than any approach that requires allocations
@@ -191,7 +223,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             }
         }
     }
-    
+
     /// <returns>
     /// True if the chunk existed and was drawn, otherwise false
     /// </returns>
@@ -441,6 +473,20 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// Surface is NOT THREAD SAFE, so if you pass a Surface here with copyImage == false you must not do anything with that surface anywhere (not even read) until CommitChanges/CancelChanges is called.
     /// </summary>
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawImage(SKMatrix transformMatrix, Surface image, SKPaint? paint = null, bool copyImage = true)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ImageOperation operation = new(transformMatrix, image, paint, copyImage);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <summary>
+    /// Be careful about the copyImage argument, see other overload for details
+    /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawImage(ShapeCorners corners, Surface image, SKPaint? paint = null, bool copyImage = true)
     {
         lock (lockObject)
@@ -452,10 +498,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     }
 
     /// <summary>
-    /// Be careful about the copyImage argument. The default is true, and this is a thread safe version without any side effects. 
-    /// It will however copy the surface right away which can be slow (in updateable changes especially). 
-    /// If copyImage is set to false, the image won't be copied and instead a reference will be stored.
-    /// Surface is NOT THREAD SAFE, so if you pass a Surface here with copyImage == false you must not do anything with that surface anywhere (not even read) until CommitChanges/CancelChanges is called.
+    /// Be careful about the copyImage argument, see other overload for details
     /// </summary>
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawImage(VecI pos, Surface image, SKPaint? paint = null, bool copyImage = true)
@@ -546,6 +589,17 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueClearPath(SKPath path, RectI? pathTightBounds = null)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ClearPathOperation operation = new(path, pathTightBounds);
+            EnqueueOperation(operation);
+        }
+    }
+
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueClear()
     {

+ 1 - 0
src/ChunkyImageLib/IReadOnlyChunkyImage.cs

@@ -6,6 +6,7 @@ namespace ChunkyImageLib;
 public interface IReadOnlyChunkyImage
 {
     bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null);
+    RectI? FindLatestBounds();
     SKColor GetCommittedPixel(VecI posOnImage);
     SKColor GetMostUpToDatePixel(VecI posOnImage);
     bool LatestOrCommittedChunkExists(VecI chunkPos);

+ 53 - 0
src/ChunkyImageLib/Operations/ClearPathOperation.cs

@@ -0,0 +1,53 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class ClearPathOperation : IDrawOperation
+{
+    private SKPath path;
+    private RectI pathTightBounds;
+
+    public bool IgnoreEmptyChunks => true;
+
+    public ClearPathOperation(SKPath path, RectI? pathTightBounds = null)
+    {
+        this.path = new SKPath(path);
+        this.pathTightBounds = (RectI)(pathTightBounds ?? path.TightBounds);
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        chunk.Surface.SkiaSurface.Canvas.Save();
+
+        using SKPath transformedPath = new(path);
+        float scale = (float)chunk.Resolution.Multiplier();
+        VecD trans = -chunkPos * ChunkyImage.FullChunkSize * scale;
+        transformedPath.Transform(SKMatrix.CreateScaleTranslation(scale, scale, (float)trans.X, (float)trans.Y));
+        chunk.Surface.SkiaSurface.Canvas.ClipPath(transformedPath);
+        chunk.Surface.SkiaSurface.Canvas.Clear();
+        chunk.Surface.SkiaSurface.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks(VecI imageSize)
+    {
+        return OperationHelper.FindChunksTouchingRectangle(pathTightBounds, ChunkPool.FullChunkSize);
+    }
+    public void Dispose()
+    {
+        path.Dispose();
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        var matrix = SKMatrix.CreateScale(verAxisX is not null ? -1 : 1, horAxisY is not null ? -1 : 1, verAxisX ?? 0, horAxisY ?? 0);
+        using var copy = new SKPath(path);
+        copy.Transform(matrix);
+
+        var newRect = pathTightBounds;
+        if (verAxisX is not null)
+            newRect = newRect.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newRect = newRect.ReflectY((int)horAxisY);
+        return new ClearPathOperation(copy, newRect);
+    }
+}

+ 1 - 1
src/ChunkyImageLib/Operations/ClearRegionOperation.cs

@@ -5,7 +5,7 @@ namespace ChunkyImageLib.Operations;
 
 internal class ClearRegionOperation : IDrawOperation
 {
-    RectI rect;
+    private RectI rect;
 
     public bool IgnoreEmptyChunks => true;
 

+ 24 - 0
src/ChunkyImageLib/Operations/ImageOperation.cs

@@ -53,6 +53,30 @@ internal class ImageOperation : IDrawOperation
         imageWasCopied = copyImage;
     }
 
+    public ImageOperation(SKMatrix transformMatrix, Surface image, SKPaint? paint = null, bool copyImage = true)
+    {
+        if (paint is not null)
+            customPaint = paint.Clone();
+
+        this.corners = new ShapeCorners()
+        {
+            TopLeft = transformMatrix.MapPoint(0, 0),
+            TopRight = transformMatrix.MapPoint(image.Size.X, 0),
+            BottomLeft = transformMatrix.MapPoint(0, image.Size.Y),
+            BottomRight = transformMatrix.MapPoint(image.Size),
+        };
+        this.transformMatrix = transformMatrix;
+
+        // copying is needed for thread safety
+        if (copyImage)
+            toPaint = new Surface(image);
+        else
+            toPaint = image;
+        imageWasCopied = copyImage;
+    }
+
+
+
     public void DrawOnChunk(Chunk chunk, VecI chunkPos)
     {
         //customPaint.FilterQuality = chunk.Resolution != ChunkResolution.Full;

+ 2 - 0
src/Custom.ruleset

@@ -19,6 +19,7 @@
     <Rule Id="SA1005" Action="None" />
     <Rule Id="SA1008" Action="None" />
     <Rule Id="SA1009" Action="None" />
+    <Rule Id="SA1011" Action="None" />
     <Rule Id="SA1023" Action="None" />
     <Rule Id="SA1028" Action="None" />
     <Rule Id="SA1101" Action="None" />
@@ -62,6 +63,7 @@
     <Rule Id="SA1405" Action="None" />
     <Rule Id="SA1406" Action="None" />
     <Rule Id="SA1407" Action="None" />
+    <Rule Id="SA1408" Action="None" />
     <Rule Id="SA1410" Action="None" />
     <Rule Id="SA1411" Action="None" />
     <Rule Id="SA1413" Action="None" />

+ 1 - 2
src/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelectedArea_Change.cs

@@ -33,8 +33,7 @@ internal class ClearSelectedArea_Change : Change
         RectD bounds = target.Selection.SelectionPath.Bounds;
         RectI intBounds = (RectI)bounds.Intersect(SKRect.Create(0, 0, target.Size.X, target.Size.Y)).RoundOutwards();
 
-        image.SetClippingPath(target.Selection.SelectionPath);
-        image.EnqueueClearRegion(intBounds);
+        image.EnqueueClearPath(target.Selection.SelectionPath, intBounds);
         var affChunks = image.FindAffectedChunks();
         savedChunks = new(image, affChunks);
         image.CommitChanges();

+ 7 - 3
src/PixiEditor.ChangeableDocument/Changes/Drawing/ShiftLayer_UpdateableChange.cs

@@ -2,14 +2,16 @@
 internal class ShiftLayer_UpdateableChange : UpdateableChange
 {
     private readonly Guid layerGuid;
+    private bool keepOriginal;
     private VecI delta;
     private CommittedChunkStorage? originalLayerChunks;
 
     [GenerateUpdateableChangeActions]
-    public ShiftLayer_UpdateableChange(Guid layerGuid, VecI delta)
+    public ShiftLayer_UpdateableChange(Guid layerGuid, VecI delta, bool keepOriginal)
     {
         this.delta = delta;
         this.layerGuid = layerGuid;
+        this.keepOriginal = keepOriginal;
     }
 
     public override OneOf<Success, Error> InitializeAndValidate(Document target)
@@ -20,9 +22,10 @@ internal class ShiftLayer_UpdateableChange : UpdateableChange
     }
 
     [UpdateChangeMethod]
-    public void Update(VecI delta)
+    public void Update(VecI delta, bool keepOriginal)
     {
         this.delta = delta;
+        this.keepOriginal = keepOriginal;
     }
 
     private HashSet<VecI> DrawShiftedLayer(Document target)
@@ -30,7 +33,8 @@ internal class ShiftLayer_UpdateableChange : UpdateableChange
         var targetImage = target.FindMemberOrThrow<Layer>(layerGuid).LayerImage;
         var prevChunks = targetImage.FindAffectedChunks();
         targetImage.CancelChanges();
-        targetImage.EnqueueClear();
+        if (!keepOriginal)
+            targetImage.EnqueueClear();
         targetImage.EnqueueDrawChunkyImage(delta, targetImage, false, false);
         var curChunks = targetImage.FindAffectedChunks();
         curChunks.UnionWith(prevChunks);

+ 183 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/TransformSelectedArea_UpdateableChange.cs

@@ -0,0 +1,183 @@
+using ChunkyImageLib.Operations;
+using PixiEditor.ChangeableDocument.Changes.Selection;
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+internal class TransformSelectedArea_UpdateableChange : UpdateableChange
+{
+    private readonly Guid[] membersToTransform;
+    private readonly bool drawOnMask;
+    private bool keepOriginal;
+    private ShapeCorners corners;
+
+    private Dictionary<Guid, (Surface surface, VecI pos)>? images;
+    private SKMatrix globalMatrix;
+    private RectI originalTightBounds;
+    private Dictionary<Guid, CommittedChunkStorage>? savedChunks;
+
+    private SKPath? originalPath;
+
+    private bool hasEnqueudImages = false;
+
+    private static SKPaint RegularPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.SrcOver };
+
+    [GenerateUpdateableChangeActions]
+    public TransformSelectedArea_UpdateableChange(
+        IEnumerable<Guid> membersToTransform,
+        ShapeCorners corners,
+        bool keepOriginal,
+        bool transformMask)
+    {
+        this.membersToTransform = membersToTransform.Select(static a => a).ToArray();
+        this.corners = corners;
+        this.keepOriginal = keepOriginal;
+        this.drawOnMask = transformMask;
+    }
+
+    public override OneOf<Success, Error> InitializeAndValidate(Document target)
+    {
+        if (membersToTransform.Length == 0 || target.Selection.SelectionPath.IsEmpty)
+            return new Error();
+
+        foreach (var guid in membersToTransform)
+        {
+            if (!DrawingChangeHelper.IsValidForDrawing(target, guid, drawOnMask))
+                return new Error();
+        }
+
+        originalPath = new SKPath(target.Selection.SelectionPath) { FillType = SKPathFillType.EvenOdd };
+        RectI bounds = (RectI)originalPath.TightBounds;
+
+        images = new();
+        foreach (var guid in membersToTransform)
+        {
+            ChunkyImage image = DrawingChangeHelper.GetTargetImageOrThrow(target, guid, drawOnMask);
+            var extracted = ExtractArea(image, originalPath, bounds);
+            if (extracted.IsT0)
+                continue;
+            images.Add(guid, (extracted.AsT1.image, extracted.AsT1.extractedRect.Pos));
+        }
+        if (images.Count == 0)
+            return new Error();
+        originalTightBounds = bounds;
+        globalMatrix = OperationHelper.CreateMatrixFromPoints(corners, originalTightBounds.Size);
+        return new Success();
+    }
+
+    public OneOf<None, (Surface image, RectI extractedRect)> ExtractArea(ChunkyImage image, SKPath path, RectI pathBounds)
+    {
+        // get rid of transparent areas on edges
+        var memberImageBounds = image.FindLatestBounds();
+        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
+        SKPath clipPath = new SKPath(path) { FillType = SKPathFillType.EvenOdd };
+        clipPath.Transform(SKMatrix.CreateTranslation(-pathBounds.X, -pathBounds.Y));
+
+        // draw
+        Surface output = new(pathBounds.Size);
+        output.SkiaSurface.Canvas.Save();
+        output.SkiaSurface.Canvas.ClipPath(clipPath);
+        image.DrawMostUpToDateRegionOn(pathBounds, ChunkResolution.Full, output.SkiaSurface, VecI.Zero);
+        output.SkiaSurface.Canvas.Restore();
+
+        return (output, pathBounds);
+    }
+
+    [UpdateChangeMethod]
+    public void Update(ShapeCorners corners, bool keepOriginal)
+    {
+        this.keepOriginal = keepOriginal;
+        this.corners = corners;
+        globalMatrix = OperationHelper.CreateMatrixFromPoints(corners, originalTightBounds.Size);
+    }
+
+    private HashSet<VecI> DrawImage(Document doc, Guid memberGuid, Surface image, VecI originalPos, ChunkyImage memberImage)
+    {
+        var prevChunks = memberImage.FindAffectedChunks();
+
+        memberImage.CancelChanges();
+
+        if (!keepOriginal)
+            memberImage.EnqueueClearPath(originalPath!, originalTightBounds);
+        SKMatrix localMatrix = SKMatrix.CreateTranslation(originalPos.X - originalTightBounds.Left, originalPos.Y - originalTightBounds.Top);
+        localMatrix = localMatrix.PostConcat(globalMatrix);
+        memberImage.EnqueueDrawImage(localMatrix, image, RegularPaint, false);
+        hasEnqueudImages = true;
+
+        var affectedChunks = memberImage.FindAffectedChunks();
+        affectedChunks.UnionWith(prevChunks);
+        return affectedChunks;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        if (savedChunks is not null)
+            throw new InvalidOperationException("Apply called twice");
+        savedChunks = new();
+
+        List<IChangeInfo> infos = new();
+        foreach (var (guid, (image, pos)) in images!)
+        {
+            ChunkyImage memberImage = DrawingChangeHelper.GetTargetImageOrThrow(target, guid, drawOnMask);
+            var chunks = DrawImage(target, guid, image, pos, memberImage);
+            savedChunks[guid] = new(memberImage, memberImage.FindAffectedChunks());
+            memberImage.CommitChanges();
+            infos.Add(DrawingChangeHelper.CreateChunkChangeInfo(guid, chunks, drawOnMask).AsT1);
+        }
+
+        infos.Add(SelectionChangeHelper.DoSelectionTransform(target, originalPath!, originalTightBounds, corners));
+
+        hasEnqueudImages = false;
+        ignoreInUndo = false;
+        return infos;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        List<IChangeInfo> infos = new();
+        foreach (var (guid, (image, pos)) in images!)
+        {
+            ChunkyImage targetImage = DrawingChangeHelper.GetTargetImageOrThrow(target, guid, drawOnMask);
+            infos.Add(DrawingChangeHelper.CreateChunkChangeInfo(guid, DrawImage(target, guid, image, pos, targetImage), drawOnMask).AsT1);
+        }
+        infos.Add(SelectionChangeHelper.DoSelectionTransform(target, originalPath!, originalTightBounds, corners));
+        return infos;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        List<IChangeInfo> infos = new();
+        foreach (var (guid, storage) in savedChunks!)
+        {
+            var storageCopy = storage;
+            var chunks = DrawingChangeHelper.ApplyStoredChunksDisposeAndSetToNull(target, guid, drawOnMask, ref storageCopy);
+            infos.Add(DrawingChangeHelper.CreateChunkChangeInfo(guid, chunks, drawOnMask).AsT1);
+        }
+        savedChunks = null;
+        return infos;
+    }
+
+    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 (_, (image, _)) in images!)
+        {
+            image.Dispose();
+        }
+
+        if (savedChunks is not null)
+        {
+            foreach (var (_, chunks) in savedChunks)
+            {
+                chunks.Dispose();
+            }
+        }
+    }
+}

+ 22 - 0
src/PixiEditor.ChangeableDocument/Changes/Selection/SelectionChangeHelper.cs

@@ -0,0 +1,22 @@
+using ChunkyImageLib.Operations;
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Changes.Selection;
+internal class SelectionChangeHelper
+{
+    public static Selection_ChangeInfo DoSelectionTransform(
+        Document target, SKPath originalPath, RectI originalPathTightBounds, ShapeCorners to)
+    {
+        SKPath newPath = new(originalPath);
+
+        var matrix = SKMatrix.CreateTranslation((float)-originalPathTightBounds.X, (float)-originalPathTightBounds.Y).PostConcat(
+            OperationHelper.CreateMatrixFromPoints(to, originalPathTightBounds.Size));
+        newPath.Transform(matrix);
+
+        var toDispose = target.Selection.SelectionPath;
+        target.Selection.SelectionPath = newPath;
+        toDispose.Dispose();
+
+        return new Selection_ChangeInfo(new SKPath(target.Selection.SelectionPath));
+    }
+}

+ 34 - 0
src/PixiEditor.ChangeableDocument/Changes/Selection/SetSelection_Change.cs

@@ -0,0 +1,34 @@
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Changes.Selection;
+internal class SetSelection_Change : Change
+{
+    private readonly SKPath selection;
+    private SKPath? originalSelection;
+
+    [GenerateMakeChangeAction]
+    public SetSelection_Change(SKPath selection)
+    {
+        this.selection = new SKPath(selection) { FillType = SKPathFillType.EvenOdd };
+    }
+
+    public override OneOf<Success, Error> InitializeAndValidate(Document target)
+    {
+        originalSelection = ((IReadOnlySelection)target.Selection).SelectionPath;
+        return new Success();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        target.Selection.SelectionPath = new SKPath(selection) { FillType = SKPathFillType.EvenOdd };
+        ignoreInUndo = false;
+        return new Selection_ChangeInfo(new SKPath(selection) { FillType = SKPathFillType.EvenOdd });
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        target.Selection.SelectionPath = new SKPath(originalSelection) { FillType = SKPathFillType.EvenOdd };
+        return new Selection_ChangeInfo(new SKPath(originalSelection) { FillType = SKPathFillType.EvenOdd });
+    }
+}

+ 5 - 22
src/PixiEditor.ChangeableDocument/Changes/Selection/TransformSelectionPath_UpdateableChange.cs

@@ -1,11 +1,10 @@
-using ChunkyImageLib.Operations;
-using SkiaSharp;
+using SkiaSharp;
 
 namespace PixiEditor.ChangeableDocument.Changes.Selection;
 internal class TransformSelectionPath_UpdateableChange : UpdateableChange
 {
     private SKPath? originalPath;
-    private ShapeCorners originalCorners;
+    private RectI originalTightBounds;
     private ShapeCorners newCorners;
 
     [GenerateUpdateableChangeActions]
@@ -25,35 +24,19 @@ internal class TransformSelectionPath_UpdateableChange : UpdateableChange
         if (target.Selection.SelectionPath.IsEmpty)
             return new Error();
         originalPath = new(target.Selection.SelectionPath);
-        var bounds = originalPath.TightBounds;
-        originalCorners = new(bounds);
+        originalTightBounds = (RectI)originalPath.TightBounds;
         return new Success();
     }
 
-    private Selection_ChangeInfo CommonApply(Document target)
-    {
-        SKPath newPath = new(originalPath);
-
-        var matrix = SKMatrix.CreateTranslation((float)-originalCorners.TopLeft.X, (float)-originalCorners.TopLeft.Y).PostConcat(
-            OperationHelper.CreateMatrixFromPoints(newCorners, originalCorners.RectSize));
-        newPath.Transform(matrix);
-
-        var toDispose = target.Selection.SelectionPath;
-        target.Selection.SelectionPath = newPath;
-        toDispose.Dispose();
-
-        return new Selection_ChangeInfo(new SKPath(target.Selection.SelectionPath));
-    }
-
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
     {
         ignoreInUndo = false;
-        return CommonApply(target);
+        return SelectionChangeHelper.DoSelectionTransform(target, originalPath!, originalTightBounds, newCorners);
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
-        return CommonApply(target);
+        return SelectionChangeHelper.DoSelectionTransform(target, originalPath!, originalTightBounds, newCorners);
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)

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

@@ -7,9 +7,8 @@ using System.Windows.Media;
 using System.Windows.Media.Imaging;
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
-using PixiEditor.ChangeableDocument.Enums;
 using PixiEditor.Helpers;
-using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.IO;
 using PixiEditor.ViewModels.SubViewModels.Document;
 
@@ -22,7 +21,7 @@ internal static class ClipboardController
         Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
         "PixiEditor",
         "Copied.png");
-    
+
     /// <summary>
     ///     Copies the selection to clipboard in PNG, Bitmap and DIB formats.
     /// </summary>
@@ -30,14 +29,19 @@ internal static class ClipboardController
     {
         if (!ClipboardHelper.TryClear())
             return;
-        
-        using Surface? surface = document.MaybeExtractSelectedArea();
-        if (surface is null)
-            return;
 
+        var surface = document.MaybeExtractSelectedArea();
+        if (surface.IsT0)
+            return;
+        if (surface.IsT1)
+        {
+            NoticeDialog.Show("Selected area is empty", "Nothing to copy");
+            return;
+        }
+        var (actuallySurface, _) = surface.AsT2;
         DataObject data = new DataObject();
 
-        using (SKData pngData = surface.SkiaSurface.Snapshot().Encode())
+        using (SKData pngData = actuallySurface.SkiaSurface.Snapshot().Encode())
         {
             // Stream should not be disposed
             MemoryStream pngStream = new MemoryStream();
@@ -52,7 +56,7 @@ internal static class ClipboardController
             data.SetFileDropList(new StringCollection() { TempCopyFilePath });
         }
 
-        WriteableBitmap finalBitmap = surface.ToWriteableBitmap();
+        WriteableBitmap finalBitmap = actuallySurface.ToWriteableBitmap();
         data.SetData(DataFormats.Bitmap, finalBitmap, true); // Bitmap, no transparency
         data.SetImage(finalBitmap); // DIB format, no transparency
 
@@ -77,7 +81,7 @@ internal static class ClipboardController
         document.Operations.PasteImagesAsLayers(images);
         return true;
     }
-    
+
     /// <summary>
     /// Gets images from clipboard, supported PNG, Dib and Bitmap.
     /// </summary>
@@ -114,7 +118,7 @@ internal static class ClipboardController
         }
         return surfaces;
     }
-    
+
     public static bool IsImageInClipboard()
     {
         DataObject dao = ClipboardHelper.TryGetDataObject();

+ 19 - 8
src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs

@@ -29,13 +29,22 @@ internal class ChangeExecutionController
         this.internals = internals;
     }
 
-    public bool TryStartUpdateableChange<T>()
+    public ExecutorType GetCurrentExecutorType()
+    {
+        if (currentSession is null)
+            return ExecutorType.None;
+        return currentSession.Type;
+    }
+
+    public bool TryStartExecutor<T>(bool force = false)
         where T : UpdateableChangeExecutor, new()
     {
-        if (currentSession is not null)
+        if (currentSession is not null && !force)
             return false;
+        if (force)
+            currentSession?.ForceStop();
         T executor = new T();
-        executor.Initialize(document, internals, this, EndChange);
+        executor.Initialize(document, internals, this, EndExecutor);
         if (executor.Start() == ExecutionState.Success)
         {
             currentSession = executor;
@@ -44,11 +53,13 @@ internal class ChangeExecutionController
         return false;
     }
 
-    public bool TryStartUpdateableChange(UpdateableChangeExecutor brandNewExecutor)
+    public bool TryStartExecutor(UpdateableChangeExecutor brandNewExecutor, bool force = false)
     {
-        if (currentSession is not null)
+        if (currentSession is not null && !force)
             return false;
-        brandNewExecutor.Initialize(document, internals, this, EndChange);
+        if (force)
+            currentSession?.ForceStop();
+        brandNewExecutor.Initialize(document, internals, this, EndExecutor);
         if (brandNewExecutor.Start() == ExecutionState.Success)
         {
             currentSession = brandNewExecutor;
@@ -57,14 +68,14 @@ internal class ChangeExecutionController
         return false;
     }
 
-    private void EndChange(UpdateableChangeExecutor executor)
+    private void EndExecutor(UpdateableChangeExecutor executor)
     {
         if (executor != currentSession)
             throw new InvalidOperationException();
         currentSession = null;
     }
 
-    public bool TryStopActiveUpdateableChange()
+    public bool TryStopActiveExecutor()
     {
         if (currentSession is null)
             return false;

+ 22 - 10
src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -1,9 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using ChunkyImageLib;
+using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.Enums;
@@ -80,7 +75,7 @@ internal class DocumentOperationsModule
     {
         if (Internals.ChangeController.IsChangeActive)
             return;
-        
+
         RectI maxSize = new RectI(VecI.Zero, Document.SizeBindable);
         foreach (var imageWithName in images)
         {
@@ -214,7 +209,24 @@ internal class DocumentOperationsModule
 
     public void PasteImageWithTransform(Surface image, VecI startPos)
     {
-        Internals.ChangeController.TryStartUpdateableChange(new PasteImageExecutor(image, startPos));
+        if (Document.SelectedStructureMember is null)
+            return;
+        Internals.ChangeController.TryStartExecutor(new PasteImageExecutor(image, startPos));
+    }
+
+    public void TransformSelectedArea(bool toolLinked)
+    {
+        if (Document.SelectedStructureMember is null ||
+            Internals.ChangeController.IsChangeActive && !toolLinked ||
+            Document.SelectionPathBindable.IsEmpty)
+            return;
+        Internals.ChangeController.TryStartExecutor(new TransformSelectedAreaExecutor(toolLinked));
+    }
+
+    public void TryStopToolLinkedExecutor()
+    {
+        if (Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked)
+            Internals.ChangeController.TryStopActiveExecutor();
     }
 
     public void DrawImage(Surface image, ShapeCorners corners, Guid memberGuid, bool ignoreClipSymmetriesEtc, bool drawOnMask) =>
@@ -222,10 +234,10 @@ internal class DocumentOperationsModule
 
     private void DrawImage(Surface image, ShapeCorners corners, Guid memberGuid, bool ignoreClipSymmetriesEtc, bool drawOnMask, bool finish)
     {
-        if (Internals.ChangeController.IsChangeActive)
+        if (Internals.ChangeController.IsChangeActive || Document.SelectedStructureMember is null)
             return;
         Internals.ActionAccumulator.AddActions(
-            new PasteImage_Action(image, corners, memberGuid, ignoreClipSymmetriesEtc, drawOnMask),
+            new PasteImage_Action(image, corners, Document.SelectedStructureMember.GuidValue, ignoreClipSymmetriesEtc, drawOnMask),
             new EndPasteImage_Action()
             );
         if (finish)

+ 25 - 10
src/PixiEditor/Models/DocumentModels/Public/DocumentToolsModule.cs

@@ -1,5 +1,6 @@
 using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+using PixiEditor.Models.Enums;
 using PixiEditor.ViewModels.SubViewModels.Document;
 
 namespace Models.DocumentModels.Public;
@@ -14,23 +15,37 @@ internal class DocumentToolsModule
         this.Internals = internals;
     }
 
-    public void UseOpacitySlider() => Internals.ChangeController.TryStartUpdateableChange<StructureMemberOpacityExecutor>();
+    public void UseOpacitySlider() => Internals.ChangeController.TryStartExecutor<StructureMemberOpacityExecutor>();
 
-    public void UsePenTool() => Internals.ChangeController.TryStartUpdateableChange<PenToolExecutor>();
+    public void UseShiftLayerTool() => Internals.ChangeController.TryStartExecutor<ShiftLayerExecutor>();
 
-    public void UseEraserTool() => Internals.ChangeController.TryStartUpdateableChange<EraserToolExecutor>();
+    public void UsePenTool() => Internals.ChangeController.TryStartExecutor<PenToolExecutor>();
 
-    public void UseColorPickerTool() => Internals.ChangeController.TryStartUpdateableChange<ColorPickerToolExecutor>();
+    public void UseEraserTool() => Internals.ChangeController.TryStartExecutor<EraserToolExecutor>();
 
-    public void UseRectangleTool() => Internals.ChangeController.TryStartUpdateableChange<RectangleToolExecutor>();
+    public void UseColorPickerTool() => Internals.ChangeController.TryStartExecutor<ColorPickerToolExecutor>();
 
-    public void UseEllipseTool() => Internals.ChangeController.TryStartUpdateableChange<EllipseToolExecutor>();
+    public void UseRectangleTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<RectangleToolExecutor>(force);
+    }
 
-    public void UseLineTool() => Internals.ChangeController.TryStartUpdateableChange<LineToolExecutor>();
+    public void UseEllipseTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<EllipseToolExecutor>(force);
+    }
+
+    public void UseLineTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<LineToolExecutor>(force);
+    }
 
-    public void UseSelectTool() => Internals.ChangeController.TryStartUpdateableChange<SelectToolExecutor>();
+    public void UseSelectTool() => Internals.ChangeController.TryStartExecutor<SelectToolExecutor>();
 
-    public void UseBrightnessTool() => Internals.ChangeController.TryStartUpdateableChange<BrightnessToolExecutor>();
+    public void UseBrightnessTool() => Internals.ChangeController.TryStartExecutor<BrightnessToolExecutor>();
 
-    public void UseFloodFillTool() => Internals.ChangeController.TryStartUpdateableChange<FloodFillToolExecutor>();
+    public void UseFloodFillTool() => Internals.ChangeController.TryStartExecutor<FloodFillToolExecutor>();
 }

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

@@ -20,6 +20,7 @@ internal class EllipseToolExecutor : ShapeToolExecutor<EllipseToolViewModel>
         internals!.ActionAccumulator.AddActions(new DrawEllipse_Action(memberGuid, rect, strokeColor, fillColor, strokeWidth, drawOnMask));
     }
 
+    public override ExecutorType Type => ExecutorType.ToolLinked;
     protected override DocumentTransformMode TransformMode => DocumentTransformMode.NoRotation;
     protected override void DrawShape(VecI currentPos) => DrawEllipseOrCircle(currentPos);
 

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

@@ -9,6 +9,7 @@ namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
 #nullable enable
 internal class LineToolExecutor : ShapeToolExecutor<LineToolViewModel>
 {
+    public override ExecutorType Type => ExecutorType.ToolLinked;
     public override ExecutionState Start()
     {
         ColorsViewModel? colorsVM = ViewModelMain.Current?.ColorsSubViewModel;

+ 2 - 7
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs

@@ -1,9 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using ChunkyImageLib;
+using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using PixiEditor.Models.Enums;
 using PixiEditor.ViewModels.SubViewModels.Document;
@@ -39,7 +34,7 @@ internal class PasteImageExecutor : UpdateableChangeExecutor
 
         ShapeCorners corners = new(new RectD(pos, image.Size));
         internals!.ActionAccumulator.AddActions(new PasteImage_Action(image, corners, memberGuid, false, drawOnMask));
-        document.TransformViewModel.ShowTransform(DocumentTransformMode.Freeform, corners);
+        document.TransformViewModel.ShowTransform(DocumentTransformMode.Freeform, true, corners);
 
         return ExecutionState.Success;
     }

+ 2 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/RectangleToolExecutor.cs

@@ -1,11 +1,13 @@
 using ChunkyImageLib.DataHolders;
 using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.Models.Enums;
 using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
 
 namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
 #nullable enable
 internal class RectangleToolExecutor : ShapeToolExecutor<RectangleToolViewModel>
 {
+    public override ExecutorType Type => ExecutorType.ToolLinked;
     private void DrawRectangle(VecI curPos)
     {
         RectI rect;

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

@@ -95,7 +95,7 @@ internal abstract class ShapeToolExecutor<T> : UpdateableChangeExecutor where T
         if (transforming)
             return;
         transforming = true;
-        document!.TransformViewModel.ShowTransform(TransformMode, new ShapeCorners(lastRect));
+        document!.TransformViewModel.ShowTransform(TransformMode, false, new ShapeCorners(lastRect));
     }
 
     public override void ForceStop()

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

@@ -0,0 +1,48 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class ShiftLayerExecutor : UpdateableChangeExecutor
+{
+    private Guid guidValue;
+    private VecI startPos;
+    private MoveToolToolbar? toolbar;
+
+    public override ExecutionState Start()
+    {
+        ViewModelMain? vm = ViewModelMain.Current;
+        StructureMemberViewModel? member = document!.SelectedStructureMember;
+        toolbar = (MoveToolToolbar?)(ViewModelMain.Current?.ToolsSubViewModel.GetTool<MoveToolViewModel>()?.Toolbar);
+        if (vm is null || member is not LayerViewModel layer || layer.ShouldDrawOnMask || toolbar is null)
+            return ExecutionState.Error;
+
+        guidValue = member.GuidValue;
+        startPos = controller!.LastPixelPosition;
+
+        ShiftLayer_Action action = new(guidValue, VecI.Zero, toolbar.KeepOriginalImage);
+        internals!.ActionAccumulator.AddActions(action);
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        ShiftLayer_Action action = new(guidValue, pos - startPos, toolbar!.KeepOriginalImage);
+        internals!.ActionAccumulator.AddActions(action);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndShiftLayer_Action());
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndShiftLayer_Action());
+    }
+}

+ 62 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedAreaExecutor.cs

@@ -0,0 +1,62 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class TransformSelectedAreaExecutor : UpdateableChangeExecutor
+{
+    private Guid[]? membersToTransform;
+    private MoveToolToolbar? toolbar;
+
+    public override ExecutorType Type { get; }
+
+    public TransformSelectedAreaExecutor(bool toolLinked)
+    {
+        Type = toolLinked ? ExecutorType.ToolLinked : ExecutorType.Regular;
+    }
+
+    public override ExecutionState Start()
+    {
+        toolbar = (MoveToolToolbar?)(ViewModelMain.Current?.ToolsSubViewModel.GetTool<MoveToolViewModel>()?.Toolbar);
+        if (toolbar is null || document!.SelectedStructureMember is null || document!.SelectionPathBindable.IsEmpty)
+            return ExecutionState.Error;
+
+        var members = document.SoftSelectedStructureMembers
+            .Append(document.SelectedStructureMember)
+            .Where(static m => m is LayerViewModel);
+
+        if (!members.Any())
+            return ExecutionState.Error;
+
+        ShapeCorners corners = new(document.SelectionPathBindable.TightBounds);
+        document.TransformViewModel.ShowTransform(DocumentTransformMode.Freeform, true, corners);
+        membersToTransform = members.Select(static a => a.GuidValue).ToArray();
+        internals!.ActionAccumulator.AddActions(
+            new TransformSelectedArea_Action(membersToTransform, corners, toolbar.KeepOriginalImage, false));
+        return ExecutionState.Success;
+    }
+
+    public override void OnTransformMoved(ShapeCorners corners)
+    {
+        internals!.ActionAccumulator.AddActions(
+            new TransformSelectedArea_Action(membersToTransform!, corners, toolbar!.KeepOriginalImage, false));
+    }
+
+    public override void OnTransformApplied()
+    {
+        internals!.ActionAccumulator.AddActions(new EndTransformSelectedArea_Action());
+        internals!.ActionAccumulator.AddFinishedActions();
+        document!.TransformViewModel.HideTransform();
+        onEnded!.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddActions(new EndTransformSelectedArea_Action());
+        internals!.ActionAccumulator.AddFinishedActions();
+        document!.TransformViewModel.HideTransform();
+    }
+}

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

@@ -13,6 +13,7 @@ internal abstract class UpdateableChangeExecutor
     private bool initialized = false;
 
     protected Action<UpdateableChangeExecutor>? onEnded;
+    public virtual ExecutorType Type => ExecutorType.Regular;
 
     public void Initialize(DocumentViewModel document, DocumentInternalParts internals, ChangeExecutionController controller, Action<UpdateableChangeExecutor> onEnded)
     {

+ 7 - 0
src/PixiEditor/Models/Enums/ExecutorType.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.Enums;
+internal enum ExecutorType
+{
+    None,
+    Regular,
+    ToolLinked,
+}

+ 10 - 2
src/PixiEditor/ViewModels/SubViewModels/Document/DocumentTransformViewModel.cs

@@ -48,13 +48,20 @@ internal class DocumentTransformViewModel : NotifyableObject
         set => SetProperty(ref transformActive, value);
     }
 
+    private bool coverWholeScreen;
+    public bool CoverWholeScreen
+    {
+        get => coverWholeScreen;
+        set => SetProperty(ref coverWholeScreen, value);
+    }
+
     private ShapeCorners requestedCorners;
     public ShapeCorners RequestedCorners
     {
         get => requestedCorners;
         set
         {
-            // We must raise the event if if the value hasn't changed, so I'm not using SetProperty
+            // The event must be raised even if the value hasn't changed, so I'm not using SetProperty
             requestedCorners = value;
             RaisePropertyChanged(nameof(RequestedCorners));
         }
@@ -80,13 +87,14 @@ internal class DocumentTransformViewModel : NotifyableObject
         TransformActive = false;
     }
 
-    public void ShowTransform(DocumentTransformMode mode, ShapeCorners initPos)
+    public void ShowTransform(DocumentTransformMode mode, bool coverWholeScreen, ShapeCorners initPos)
     {
         activeTransformMode = mode;
         CornerFreedom = TransformCornerFreedom.Scale;
         SideFreedom = TransformSideFreedom.Stretch;
         LockRotation = mode == DocumentTransformMode.NoRotation;
         RequestedCorners = initPos;
+        CoverWholeScreen = coverWholeScreen;
         TransformActive = true;
     }
 

+ 30 - 7
src/PixiEditor/ViewModels/SubViewModels/Document/DocumentViewModel.cs

@@ -246,16 +246,39 @@ internal class DocumentViewModel : NotifyableObject
         RaisePropertyChanged(nameof(AllChangesSaved));
     }
 
-    public Surface? MaybeExtractSelectedArea()
+    /// <summary>
+    /// Takes the selected area and converts it into a surface
+    /// </summary>
+    /// <returns><see cref="Error"/> on error, <see cref="None"/> for empty <see cref="Surface"/>, <see cref="Surface"/> otherwise.</returns>
+    public OneOf<Error, None, (Surface, RectI)> MaybeExtractSelectedArea(StructureMemberViewModel? layerToExtractFrom = null)
     {
-        if (SelectedStructureMember is null || SelectedStructureMember is not LayerViewModel layerVm || SelectionPathBindable.IsEmpty)
-            return null;
+        layerToExtractFrom ??= SelectedStructureMember;
+        if (layerToExtractFrom is null || layerToExtractFrom is not LayerViewModel layerVm)
+            return new Error();
+        if (SelectionPathBindable.IsEmpty)
+            return new None();
 
         IReadOnlyLayer? layer = (IReadOnlyLayer?)Internals.Tracker.Document.FindMember(layerVm.GuidValue);
         if (layer is null)
-            return null;
-            
+            return new Error();
+
         RectI bounds = (RectI)SelectionPathBindable.TightBounds;
+        RectI? memberImageBounds;
+        try
+        {
+            memberImageBounds = layer.LayerImage.FindLatestBounds();
+        }
+        catch (ObjectDisposedException)
+        {
+            return new Error();
+        }
+        if (memberImageBounds is null)
+            return new None();
+        bounds = bounds.Intersect(memberImageBounds.Value);
+        bounds = bounds.Intersect(new RectI(VecI.Zero, SizeBindable));
+        if (bounds.IsZeroOrNegativeArea)
+            return new None();
+
         Surface output = new(bounds.Size);
 
         SKPath clipPath = new SKPath(SelectionPathBindable) { FillType = SKPathFillType.EvenOdd };
@@ -269,11 +292,11 @@ internal class DocumentViewModel : NotifyableObject
         catch (ObjectDisposedException)
         {
             output.Dispose();
-            return null;
+            return new Error();
         }
         output.SkiaSurface.Canvas.Restore();
 
-        return output;
+        return (output, bounds);
     }
 
     public SKColor PickColor(VecI pos, bool fromAllLayers)

+ 6 - 2
src/PixiEditor/ViewModels/SubViewModels/Main/SelectionViewModel.cs

@@ -1,7 +1,5 @@
 using System.Windows.Input;
-using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Commands.Attributes.Commands;
-using PixiEditor.Models.Commands.Attributes.Evaluators;
 
 namespace PixiEditor.ViewModels.SubViewModels.Main;
 
@@ -36,4 +34,10 @@ internal class SelectionViewModel : SubViewModel<ViewModelMain>
     {
         return !Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectionPathBindable?.IsEmpty ?? false;
     }
+
+    [Command.Basic("PixiEditor.Selection.TransformArea", "Transform selected area", "Transform selected area", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.T, Modifiers = ModifierKeys.Control)]
+    public void TransformSelectedArea()
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.TransformSelectedArea(false);
+    }
 }

+ 2 - 0
src/PixiEditor/ViewModels/SubViewModels/Main/ToolsViewModel.cs

@@ -120,8 +120,10 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>
 
         //update old tool
         LastActionTool?.UpdateActionDisplay(false, false, false);
+        LastActionTool?.OnDeselected();
         //update new tool
         ActiveTool.UpdateActionDisplay(ctrlIsDown, shiftIsDown, altIsDown);
+        ActiveTool.OnSelected();
 
         tool.IsActive = true;
         SetToolCursor(tool.GetType());

+ 5 - 0
src/PixiEditor/ViewModels/SubViewModels/Tools/ShapeTool.cs

@@ -12,4 +12,9 @@ internal abstract class ShapeTool : ToolViewModel
         Cursor = Cursors.Cross;
         Toolbar = new BasicShapeToolbar();
     }
+
+    public override void OnDeselected()
+    {
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Operations.TryStopToolLinkedExecutor();
+    }
 }

+ 2 - 0
src/PixiEditor/ViewModels/SubViewModels/Tools/Tool.cs

@@ -50,4 +50,6 @@ internal abstract class ToolViewModel : NotifyableObject
 
     public virtual void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown) { }
     public virtual void OnLeftMouseButtonDown(VecD pos) { }
+    public virtual void OnSelected() { }
+    public virtual void OnDeselected() { }
 }

+ 12 - 0
src/PixiEditor/ViewModels/SubViewModels/Tools/ToolSettings/Toolbars/MoveToolToolbar.cs

@@ -0,0 +1,12 @@
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+internal class MoveToolToolbar : Toolbar
+{
+    public bool KeepOriginalImage => GetSetting<BoolSetting>(nameof(KeepOriginalImage)).Value;
+
+    public MoveToolToolbar()
+    {
+        Settings.Add(new BoolSetting(nameof(KeepOriginalImage), "Kеep original image"));
+    }
+}

+ 15 - 5
src/PixiEditor/ViewModels/SubViewModels/Tools/Tools/MoveToolViewModel.cs

@@ -1,5 +1,7 @@
 using System.Windows.Input;
+using ChunkyImageLib.DataHolders;
 using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
 using PixiEditor.Views.UserControls.BrushShapeOverlay;
 
 namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
@@ -12,6 +14,7 @@ internal class MoveToolViewModel : ToolViewModel
     public MoveToolViewModel()
     {
         ActionDisplay = defaultActionDisplay;
+        Toolbar = new MoveToolToolbar();
         Cursor = Cursors.Arrow;
     }
 
@@ -20,11 +23,18 @@ internal class MoveToolViewModel : ToolViewModel
     public override BrushShape BrushShape => BrushShape.Hidden;
     public override bool HideHighlight => true;
 
-    public override void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    public override void OnLeftMouseButtonDown(VecD pos)
     {
-        if (ctrlIsDown)
-            ActionDisplay = "Hold mouse to move all layers.";
-        else
-            ActionDisplay = defaultActionDisplay;
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseShiftLayerTool();
+    }
+
+    public override void OnSelected()
+    {
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Operations.TransformSelectedArea(true);
+    }
+
+    public override void OnDeselected()
+    {
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Operations.TryStopToolLinkedExecutor();
     }
 }

+ 15 - 2
src/PixiEditor/Views/UserControls/SelectionOverlay.cs

@@ -2,7 +2,6 @@
 using System.Windows.Controls;
 using System.Windows.Media;
 using System.Windows.Media.Animation;
-using SkiaSharp;
 
 namespace PixiEditor.Views.UserControls;
 #nullable enable
@@ -12,10 +11,18 @@ internal class SelectionOverlay : Control
         DependencyProperty.Register(nameof(Path), typeof(SKPath), typeof(SelectionOverlay),
             new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
 
-
     public static readonly DependencyProperty ZoomboxScaleProperty =
         DependencyProperty.Register(nameof(ZoomboxScale), typeof(double), typeof(SelectionOverlay), new(1.0, OnZoomboxScaleChanged));
 
+    public static readonly DependencyProperty ShowFillProperty =
+        DependencyProperty.Register(nameof(ShowFill), typeof(bool), typeof(SelectionOverlay), new(true, OnShowFillChanged));
+
+    public bool ShowFill
+    {
+        get => (bool)GetValue(ShowFillProperty);
+        set => SetValue(ShowFillProperty, value);
+    }
+
     public double ZoomboxScale
     {
         get => (double)GetValue(ZoomboxScaleProperty);
@@ -86,4 +93,10 @@ internal class SelectionOverlay : Control
         self.whitePen.Thickness = 1.0 / newScale;
         self.blackDashedPen.Thickness = 1.0 / newScale;
     }
+
+    private static void OnShowFillChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
+    {
+        var self = (SelectionOverlay)obj;
+        self.fillBrush.Opacity = (bool)args.NewValue ? 1 : 0;
+    }
 }

+ 61 - 3
src/PixiEditor/Views/UserControls/TransformOverlay/TransformOverlay.cs

@@ -42,6 +42,16 @@ internal class TransformOverlay : Decorator
     public static readonly DependencyProperty ZoomboxAngleProperty =
         DependencyProperty.Register(nameof(ZoomboxAngle), typeof(double), typeof(TransformOverlay), new(0.0));
 
+    public static readonly DependencyProperty ConverWholeScreenProperty =
+        DependencyProperty.Register(nameof(CoverWholeScreen), typeof(bool), typeof(TransformOverlay),
+            new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.AffectsRender));
+
+    public bool CoverWholeScreen
+    {
+        get => (bool)GetValue(ConverWholeScreenProperty);
+        set => SetValue(ConverWholeScreenProperty, value);
+    }
+
     public double ZoomboxAngle
     {
         get => (double)GetValue(ZoomboxAngleProperty);
@@ -96,6 +106,8 @@ internal class TransformOverlay : Decorator
         set => SetValue(LockRotationProperty, value);
     }
 
+    private const int anchorSizeMultiplierForRotation = 15;
+
     private bool isResettingRequestedCorners = false;
     private bool isMoving = false;
     private VecD mousePosOnStartMove = new();
@@ -142,11 +154,51 @@ internal class TransformOverlay : Decorator
             UpdateRotationCursor(TransformHelper.ToVecD(Mouse.GetPosition(this)));
     }
 
+    private void DrawMouseInputArea(DrawingContext context, VecD size)
+    {
+        if (CoverWholeScreen)
+        {
+            context.DrawRectangle(Brushes.Transparent, null, new Rect(new Point(-size.X * 50, -size.Y * 50), new Size(size.X * 101, size.Y * 101)));
+            return;
+        }
+
+        StreamGeometry geometry = new();
+        using (StreamGeometryContext ctx = geometry.Open())
+        {
+            ctx.BeginFigure(TransformHelper.ToPoint(Corners.TopLeft), true, true);
+            ctx.LineTo(TransformHelper.ToPoint(Corners.TopRight), true, true);
+            ctx.LineTo(TransformHelper.ToPoint(Corners.BottomRight), true, true);
+            ctx.LineTo(TransformHelper.ToPoint(Corners.BottomLeft), true, true);
+            ctx.Close();
+        }
+
+        if (LockRotation)
+            return;
+
+        context.DrawGeometry(Brushes.Transparent, null, geometry);
+        Span<Point> points = stackalloc Point[]
+        {
+            TransformHelper.ToPoint(Corners.TopLeft),
+            TransformHelper.ToPoint(Corners.TopRight),
+            TransformHelper.ToPoint(Corners.BottomLeft),
+            TransformHelper.ToPoint(Corners.BottomRight),
+            TransformHelper.ToPoint((Corners.TopLeft + Corners.TopRight) / 2),
+            TransformHelper.ToPoint((Corners.TopLeft + Corners.BottomLeft) / 2),
+            TransformHelper.ToPoint((Corners.BottomRight + Corners.TopRight) / 2),
+            TransformHelper.ToPoint((Corners.BottomRight + Corners.BottomLeft) / 2),
+        };
+        double ellipseSize = (TransformHelper.AnchorSize * anchorSizeMultiplierForRotation - 2) / (ZoomboxScale * 2);
+        foreach (var point in points)
+        {
+            context.DrawEllipse(Brushes.Transparent, null, point, ellipseSize, ellipseSize);
+        }
+    }
+
     private void DrawOverlay
         (DrawingContext context, VecD size, ShapeCorners corners, VecD origin, double zoomboxScale)
     {
-        // draw transparent background to enable mouse input everywhere
-        context.DrawRectangle(Brushes.Transparent, null, new Rect(new Point(-size.X * 50, -size.Y * 50), new Size(size.X * 101, size.Y * 101)));
+        // draw transparent background to enable mouse input
+        DrawMouseInputArea(context, size);
 
         blackPen.Thickness = 1 / zoomboxScale;
         blackDashedPen.Thickness = 1 / zoomboxScale;
@@ -201,6 +253,12 @@ internal class TransformOverlay : Decorator
         context.DrawGeometry(Brushes.White, blackPen, rotateCursorGeometry);
     }
 
+    protected override void OnMouseLeave(MouseEventArgs e)
+    {
+        base.OnMouseLeave(e);
+        rotateCursorGeometry.Transform = new ScaleTransform(0, 0);
+    }
+
     protected override void OnMouseDown(MouseButtonEventArgs e)
     {
         base.OnMouseDown(e);
@@ -245,7 +303,7 @@ internal class TransformOverlay : Decorator
             TransformHelper.GetAnchorInPosition(mousePos, Corners, InternalState.Origin, ZoomboxScale) is not null ||
             TransformHelper.IsWithinTransformHandle(TransformHelper.GetDragHandlePos(Corners, ZoomboxScale), mousePos, ZoomboxScale))
             return false;
-        return TransformHelper.GetAnchorInPosition(mousePos, Corners, InternalState.Origin, ZoomboxScale, 15) is not null;
+        return TransformHelper.GetAnchorInPosition(mousePos, Corners, InternalState.Origin, ZoomboxScale, anchorSizeMultiplierForRotation) is not null;
     }
 
     private bool UpdateRotationCursor(VecD mousePos)

+ 6 - 0
src/PixiEditor/Views/UserControls/Viewport.xaml

@@ -16,6 +16,7 @@
     xmlns:vm="clr-namespace:PixiEditor.ViewModels"
     xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
     xmlns:cmds="clr-namespace:PixiEditor.Models.Commands.XAML"
+    xmlns:tools ="clr-namespace:PixiEditor.ViewModels.SubViewModels.Tools.Tools"
     mc:Ignorable="d"
     x:Name="vpUc"
     d:DesignHeight="450"
@@ -76,6 +77,9 @@
                     </ImageBrush>
                 </Border.Background>
                 <Grid>
+                    <Grid.Resources>
+                        <converters:IsSpecifiedTypeConverter x:Key="IsSelectToolConverter" SpecifiedType="{x:Type tools:SelectToolViewModel}"/>
+                    </Grid.Resources>
                     <Canvas>
                         <Image
                             Width="{Binding Document.ReferenceBitmap.Width}"
@@ -107,6 +111,7 @@
                         DragCommand="{Binding Document.DragSymmetryCommand}"
                         DragEndCommand="{Binding Document.EndDragSymmetryCommand}" />
                     <uc:SelectionOverlay
+                        ShowFill="{Binding ToolsSubViewModel.ActiveTool, Source={vm:MainVM}, Converter={StaticResource IsSelectToolConverter}}"
                         Path="{Binding Document.SelectionPathBindable}"
                         ZoomboxScale="{Binding Zoombox.Scale}" />
                     <brush:BrushShapeOverlay
@@ -129,6 +134,7 @@
                         CornerFreedom="{Binding Document.TransformViewModel.CornerFreedom}"
                         SideFreedom="{Binding Document.TransformViewModel.SideFreedom}"
                         LockRotation="{Binding Document.TransformViewModel.LockRotation}"
+                        CoverWholeScreen="{Binding Document.TransformViewModel.CoverWholeScreen}"
                         SnapToAngles="{Binding Document.TransformViewModel.SnapToAngles}"
                         InternalState="{Binding Document.TransformViewModel.InternalState, Mode=TwoWay}"
                         ZoomboxScale="{Binding Zoombox.Scale}"

+ 1 - 1
src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs

@@ -563,7 +563,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
             return;
         updateableChangeActive = true;
         shiftingLayer = true;
-        Helpers.ActionAccumulator.AddActions(new ShiftLayer_Action(layer.GuidValue, delta));
+        Helpers.ActionAccumulator.AddActions(new ShiftLayer_Action(layer.GuidValue, delta, false));
     }
 
     public void EndShiftLayer()