소스 검색

Refactor zoom functionality by replacing `Zoom` class with `ZoomPanControl`

- Removed `Zoom` class and existing zoom-related logic.
- Added `ZoomPanControl` as a comprehensive replacement with improved modularity and support for panning, zooming, and rotation.
- Implemented `ZoomPreviewWindow` for visualizing zoom operations, including pointer-based interactions.
- Updated references to `vm.GlobalSettings.RotationAngle` to `vm.PicViewer.RotationAngle` for better alignment with the new design.
- Simplified and optimized zoom-related UI components and behaviors.
Ruben 6 달 전
부모
커밋
081f5701b4

+ 1 - 1
src/PicView.Avalonia/Crop/CropFunctions.cs

@@ -133,7 +133,7 @@ public static class CropFunctions
             return false;
         }
 
-        if (vm.GlobalSettings.RotationAngle.CurrentValue is 0 && vm.PicViewer.ScaleX.CurrentValue is 1)
+        if (vm.PicViewer.RotationAngle.CurrentValue is 0 && vm.PicViewer.ScaleX.CurrentValue is 1)
         {
             vm.PicViewer.ShouldCropBeEnabled.Value = true;
             return true;

+ 545 - 0
src/PicView.Avalonia/CustomControls/ZoomPanControl.cs

@@ -0,0 +1,545 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using R3;
+
+namespace PicView.Avalonia.CustomControls;
+
+public class ZoomPanControl : Decorator
+{
+    // Bindable properties so you can bind to them if needed.
+    public static readonly StyledProperty<double> ScaleProperty =
+        AvaloniaProperty.Register<ZoomPanControl, double>(nameof(Scale), 1.0);
+
+    public static readonly StyledProperty<double> RotationProperty =
+        AvaloniaProperty.Register<ZoomPanControl, double>(nameof(Rotation));
+
+    public static readonly StyledProperty<double> TranslateXProperty =
+        AvaloniaProperty.Register<ZoomPanControl, double>(nameof(TranslateX));
+
+    public static readonly StyledProperty<double> TranslateYProperty =
+        AvaloniaProperty.Register<ZoomPanControl, double>(nameof(TranslateY));
+
+    // Deadzone configuration
+    public static readonly StyledProperty<double> DeadzoneToleranceProperty =
+        AvaloniaProperty.Register<ZoomPanControl, double>(nameof(DeadzoneTolerance), 0.05);
+
+    public static readonly StyledProperty<bool> EnableDeadzoneProperty =
+        AvaloniaProperty.Register<ZoomPanControl, bool>(nameof(EnableDeadzone), true);
+
+    // Private fields for panning
+    private bool _isPanning;
+    private Point _panStartPointer;
+    private Point _panStartTranslate;
+
+    public double ZoomLevel { get; private set; } = 1;
+
+    // Accessors
+    public double Scale
+    {
+        get => GetValue(ScaleProperty);
+        set => SetValue(ScaleProperty, value);
+    }
+
+    /// <summary>Rotation in degrees (clockwise)</summary>
+    public double Rotation
+    {
+        get => GetValue(RotationProperty);
+        set => SetValue(RotationProperty, value);
+    }
+
+    public double TranslateX
+    {
+        get => GetValue(TranslateXProperty);
+        set => SetValue(TranslateXProperty, value);
+    }
+
+    public double TranslateY
+    {
+        get => GetValue(TranslateYProperty);
+        set => SetValue(TranslateYProperty, value);
+    }
+
+    /// <summary>
+    /// The tolerance range around 1.0 where zoom will snap to reset (1.0).
+    /// For example, 0.05 means zoom values between 0.95 and 1.05 will snap to 1.0.
+    /// </summary>
+    public double DeadzoneTolerance
+    {
+        get => GetValue(DeadzoneToleranceProperty);
+        set => SetValue(DeadzoneToleranceProperty, Math.Max(0, value));
+    }
+
+    /// <summary>
+    /// Whether the deadzone snap-to-reset feature is enabled.
+    /// </summary>
+    public bool EnableDeadzone
+    {
+        get => GetValue(EnableDeadzoneProperty);
+        set => SetValue(EnableDeadzoneProperty, value);
+    }
+
+    public void Initialize()
+    {
+        // Pointer handling for panning
+        AddHandler(PointerPressedEvent, HandlePointerPressed, RoutingStrategies.Tunnel);
+        AddHandler(PointerMovedEvent, HandlePointerMoved, RoutingStrategies.Tunnel);
+        AddHandler(PointerReleasedEvent, HandlePointerReleased, RoutingStrategies.Tunnel);
+
+        // When the child changes, ensure transforms are applied
+        ChildProperty.Changed.ToObservable().Skip(1).Subscribe(_ => UpdateChildTransform());
+
+        // When transform properties change, update RenderTransform
+        ScaleProperty.Changed.ToObservable().Skip(1).Subscribe(_ => UpdateChildTransform());
+        RotationProperty.Changed.ToObservable().Subscribe(_ => UpdateChildTransform());
+        TranslateXProperty.Changed.ToObservable().Skip(1).Subscribe(_ => UpdateChildTransform());
+        TranslateYProperty.Changed.ToObservable().Skip(1).Subscribe(_ => UpdateChildTransform());
+    }
+
+    protected override Size ArrangeOverride(Size finalSize)
+    {
+        // After layout, ensure transforms are constrained
+        ConstrainTranslationToBounds();
+        UpdateChildTransform();
+        return base.ArrangeOverride(finalSize);
+    }
+
+    private void HandlePointerPressed(object? sender, PointerPressedEventArgs e)
+    {
+        if (Child == null)
+        {
+            return;
+        }
+
+        if (e.ClickCount == 2)
+        {
+            ResetZoom(true, false);
+            return;
+        }
+
+        var p = e.GetPosition(this);
+        if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || !(Math.Abs(Scale) > 1.0001))
+        {
+            return;
+        }
+
+        _isPanning = true;
+        _panStartPointer = p;
+        _panStartTranslate = new Point(TranslateX, TranslateY);
+        e.Pointer.Capture(this);
+    }
+
+    private void HandlePointerMoved(object? sender, PointerEventArgs e)
+    {
+        if (!_isPanning || Child == null)
+        {
+            return;
+        }
+
+        var p = e.GetPosition(this);
+        var delta = p - _panStartPointer;
+
+        // delta is in control coordinates; we need to convert that into translate change respecting rotation/scale
+        // Given we compose transforms as: Result = Translate + Rotate( Scale * childPoint )
+        // The translate we manipulate is in control coordinates directly, so we can add the delta to it,
+        // but rotation means dragging direction should rotate together (so we rotate delta by -Rotation to convert?)
+        // Simpler and correct: update TranslateX/Y by delta (works because translate is last transform).
+        var newTx = _panStartTranslate.X + delta.X;
+        var newTy = _panStartTranslate.Y + delta.Y;
+
+        TranslateX = newTx;
+        TranslateY = newTy;
+
+        ConstrainTranslationToBounds();
+        UpdateChildTransform();
+    }
+
+    private void HandlePointerReleased(object? sender, PointerReleasedEventArgs e)
+    {
+        if (!_isPanning)
+        {
+            return;
+        }
+
+        _isPanning = false;
+        e.Pointer.Capture(null);
+    }
+
+    /// <summary>
+    /// Applies deadzone logic to snap zoom values close to 1.0 back to exactly 1.0.
+    /// Also resets translation when snapping to reset zoom.
+    /// </summary>
+    private double ApplyDeadzone(double targetScale, bool animated, Point? zoomPoint = null)
+    {
+        if (!EnableDeadzone || DeadzoneTolerance <= 0)
+        {
+            return targetScale;
+        }
+
+        var resetZoom = 1.0;
+        var lowerBound = resetZoom - DeadzoneTolerance;
+        var upperBound = resetZoom + DeadzoneTolerance;
+
+        // Check if target scale is within deadzone
+        if (targetScale >= lowerBound && targetScale <= upperBound)
+        {
+            // Snap to reset zoom and center the content
+            SetTransitions(animated);
+            Scale = resetZoom;
+            TranslateX = 0;
+            TranslateY = 0;
+
+            // If we have a specific zoom point and child is available, center properly
+            if (zoomPoint.HasValue && Child != null)
+            {
+                var center = CenterPoint();
+                SetScaleImmediate(resetZoom, center);
+            }
+            else
+            {
+                SetScaleImmediate(resetZoom, CenterPoint());
+            }
+
+            return resetZoom;
+        }
+
+        return targetScale;
+    }
+
+    public void ResetZoom(bool animated, bool resetFlipAndRotation)
+    {
+        if (Child == null)
+        {
+            return;
+        }
+
+        if (resetFlipAndRotation)
+        {
+            Rotation = 0;
+        }
+
+        SetTransitions(animated);
+        Scale = TranslateX = TranslateY = 1.0;
+        SetScaleImmediate(1.0, CenterPoint());
+    }
+
+    public void ZoomWithPointerWheel(PointerWheelEventArgs e) =>
+        ZoomWithPointerWheelCore(e.Delta.Y > 0, e.GetPosition(this));
+
+    public void ZoomWithPointerWheel(PointerDeltaEventArgs e) =>
+        ZoomWithPointerWheelCore(e.Delta.Y > 0, e.GetPosition(this));
+
+    private void ZoomWithPointerWheelCore(bool isZoomIn, Point pos)
+    {
+        var step = isZoomIn ? Settings.Zoom.ZoomSpeed : -Math.Abs(Settings.Zoom.ZoomSpeed);
+        var shouldAnimate = true; // TODO: Add zoom animation toggle setting
+        ZoomBy(step, shouldAnimate, pos);
+    }
+
+    public void ZoomBy(double multiplier, bool animated = true, Point? zoomAtPoint = null)
+    {
+        var center = zoomAtPoint ?? CenterPoint();
+        var targetScale = Math.Max(0.09, Scale + multiplier);
+
+        if (Settings.Zoom.AvoidZoomingOut && targetScale < 1)
+        {
+            return;
+        }
+
+        // Apply deadzone logic
+        targetScale = ApplyDeadzone(targetScale, animated, center);
+
+        // Only animate if deadzone didn't handle the zoom
+        if (Math.Abs(targetScale - Scale) > 1e-9)
+        {
+            AnimateScaleTo(targetScale, center, animated);
+        }
+    }
+
+    /// <summary>
+    /// Zoom in. If zoomAtCursorPoint is provided it will zoom at that point; otherwise uses control center.
+    /// </summary>
+    public void ZoomIn(double multiplier = 1.2, bool animated = true,
+        Point? zoomAtCursorPoint = null)
+    {
+        var center = zoomAtCursorPoint ?? CenterPoint();
+        var targetScale = Scale * multiplier;
+
+        // Apply deadzone logic
+        targetScale = ApplyDeadzone(targetScale, animated, center);
+
+        if (Math.Abs(targetScale - Scale) > 1e-9)
+        {
+            AnimateScaleTo(targetScale, center, animated);
+        }
+    }
+
+    /// <summary>
+    /// Zoom out. Always uses the control center as requested.
+    /// </summary>
+    public void ZoomOut(double multiplier = 1.0 / 1.2, bool animated = true)
+    {
+        var center = CenterPoint();
+        var targetScale = Scale * multiplier;
+
+        // Apply deadzone logic
+        targetScale = ApplyDeadzone(targetScale, animated, center);
+
+        if (Math.Abs(targetScale - Scale) > 1e-9)
+        {
+            AnimateScaleTo(targetScale, center, animated);
+        }
+    }
+
+    /// <summary>
+    /// Immediate (no animation) set of scale around given control point.
+    /// </summary>
+    public void SetScaleImmediate(double newScale, Point? around = null)
+    {
+        var center = around ?? CenterPoint();
+        ApplyScaleAroundPoint(newScale, center);
+        ConstrainTranslationToBounds();
+        UpdateChildTransform();
+    }
+
+    private Point CenterPoint()
+    {
+        return new Point(Bounds.Width / 2.0, Bounds.Height / 2.0);
+    }
+
+    private void AnimateScaleTo(double targetScale, Point center, bool animated)
+    {
+        SetTransitions(animated);
+        SetScaleImmediate(targetScale, center);
+    }
+
+    private void SetTransitions(bool isAnimated)
+    {
+        if (!isAnimated)
+        {
+            Transitions = null;
+        }
+        else
+        {
+            Transitions ??=
+            [
+                new DoubleTransition
+                {
+                    Property = ScaleTransform.ScaleXProperty,
+                    Duration = TimeSpan.FromSeconds(.25)
+                },
+
+                new DoubleTransition
+                {
+                    Property = ScaleTransform.ScaleYProperty,
+                    Duration = TimeSpan.FromSeconds(.25)
+                },
+
+                new DoubleTransition
+                {
+                    Property = TranslateTransform.XProperty,
+                    Duration = TimeSpan.FromSeconds(.25)
+                },
+
+                new DoubleTransition
+                {
+                    Property = TranslateTransform.YProperty,
+                    Duration = TimeSpan.FromSeconds(.25)
+                }
+            ];
+        }
+    }
+
+    /// <summary>
+    /// Applies the scale change so that the child point under `controlPoint` remains fixed in control coordinates.
+    /// Takes rotation and flipping into account.
+    /// Transform order used: Result = Translate + Rotate( Scale * childPoint ).
+    /// </summary>
+    private void ApplyScaleAroundPoint(double newScale, Point controlPoint)
+    {
+        if (Child == null)
+        {
+            return;
+        }
+
+        // Current params
+        var s = Scale;
+        var sNew = newScale;
+        if (Math.Abs(s - sNew) < 1e-9)
+        {
+            return;
+        }
+
+        var angleDeg = Rotation;
+        var angleRad = angleDeg * Math.PI / 180.0;
+
+        // Current translate
+        var tx = TranslateX;
+        var ty = TranslateY;
+
+        // We want child point pChild such that: controlPoint = (tx,ty) + R( s * pChild )
+        // => pChild = (1/s) * R^{-1}( controlPoint - t )
+        // after scale: t' = controlPoint - R( sNew * pChild )
+        // compute:
+        var cpMinusT = new Point(controlPoint.X - tx, controlPoint.Y - ty);
+
+        // R^{-1} rotate by -angle
+        var cos = Math.Cos(-angleRad);
+        var sin = Math.Sin(-angleRad);
+        var px = (cpMinusT.X * cos - cpMinusT.Y * sin) / s;
+        var py = (cpMinusT.X * sin + cpMinusT.Y * cos) / s;
+
+        // Now compute new translation so that R( sNew * pChild ) + t' = controlPoint
+        var cos2 = Math.Cos(angleRad);
+        var sin2 = Math.Sin(angleRad);
+        var rotatedX = sNew * (px * cos2 - py * sin2);
+        var rotatedY = sNew * (px * sin2 + py * cos2);
+
+        var newTx = controlPoint.X - rotatedX;
+        var newTy = controlPoint.Y - rotatedY;
+
+        // Commit
+        Scale = sNew;
+        TranslateX = newTx;
+        TranslateY = newTy;
+    }
+
+    /// <summary>
+    /// Applies the RenderTransform on the child according to current properties.
+    /// Transform order: Scale (including flipping) -> Rotate -> Translate.
+    /// </summary>
+    private void UpdateChildTransform()
+    {
+        if (Child == null)
+        {
+            return;
+        }
+
+        // Build transform group
+        var group = new TransformGroup();
+
+        group.Children.Add(new ScaleTransform(Scale, Scale));
+        group.Children.Add(new RotateTransform(Rotation));
+        group.Children.Add(new TranslateTransform(TranslateX, TranslateY));
+
+        Child.RenderTransform = group;
+        Child.RenderTransformOrigin = new RelativePoint(0, 0, RelativeUnit.Absolute);
+
+        ZoomLevel = TranslateX;
+    }
+
+    /// <summary>
+    /// Ensures the transformed child covers the control area (i.e. prevents panning away until whitespace appears).
+    /// Works when rotated/flipped because we compute transformed corners and clamp them against control bounds.
+    /// </summary>
+    private void ConstrainTranslationToBounds()
+    {
+        if (Child == null)
+        {
+            return;
+        }
+
+        // We need the child's size in local coordinates
+        var childSize = Child.Bounds.Size;
+        if (childSize.Width <= 0 || childSize.Height <= 0 || double.IsNaN(childSize.Width) ||
+            double.IsNaN(childSize.Height))
+        {
+            // Fallback to desired size
+            childSize = Child.DesiredSize;
+        }
+
+        if (childSize.Width <= 0 || childSize.Height <= 0)
+        {
+            return;
+        }
+
+        // Transform the 4 corners through our transform (Scale + Rotate + Translate)
+        var angleRad = Rotation * Math.PI / 180.0;
+        var cos = Math.Cos(angleRad);
+        var sin = Math.Sin(angleRad);
+
+        var corners = new[]
+        {
+            TransformPointLocal(new Point(0, 0)),
+            TransformPointLocal(new Point(childSize.Width, 0)),
+            TransformPointLocal(new Point(childSize.Width, childSize.Height)),
+            TransformPointLocal(new Point(0, childSize.Height))
+        };
+
+        var minX = corners.Min(c => c.X);
+        var maxX = corners.Max(c => c.X);
+        var minY = corners.Min(c => c.Y);
+        var maxY = corners.Max(c => c.Y);
+
+        var controlWidth = Bounds.Width;
+        var controlHeight = Bounds.Height;
+
+        // If transformed content is smaller than control in any axis, center it (so user sees content)
+        var desiredTx = TranslateX;
+        var desiredTy = TranslateY;
+
+        // Horizontal
+        var contentWidth = maxX - minX;
+        if (contentWidth <= controlWidth)
+        {
+            // center horizontally
+            var centerOffset = (controlWidth - contentWidth) / 2.0 - minX;
+            desiredTx += centerOffset;
+        }
+        else
+        {
+            // ensure minX <= 0 and maxX >= controlWidth
+            if (minX > 0)
+            {
+                desiredTx -= minX;
+            }
+
+            if (maxX < controlWidth)
+            {
+                desiredTx += controlWidth - maxX;
+            }
+        }
+
+        // Vertical
+        var contentHeight = maxY - minY;
+        if (contentHeight <= controlHeight)
+        {
+            var centerOffset = (controlHeight - contentHeight) / 2.0 - minY;
+            desiredTy += centerOffset;
+        }
+        else
+        {
+            if (minY > 0)
+            {
+                desiredTy -= minY;
+            }
+
+            if (maxY < controlHeight)
+            {
+                desiredTy += controlHeight - maxY;
+            }
+        }
+
+        // Apply clamped translation
+        TranslateX = desiredTx;
+        TranslateY = desiredTy;
+        return;
+
+        Point TransformPointLocal(Point p)
+        {
+            // Scale
+            var sx = Scale * p.X;
+            var sy = Scale * p.Y;
+
+            // Rotate
+            var rx = sx * cos - sy * sin;
+            var ry = sx * sin + sy * cos;
+
+            // Translate
+            return new Point(rx + TranslateX, ry + TranslateY);
+        }
+    }
+}

+ 2 - 2
src/PicView.Avalonia/FileSystem/FileSaverHelper.cs

@@ -66,7 +66,7 @@ public static class FileSaverHelper
                 null,
                 null,
                 Path.GetExtension(destination),
-                vm.GlobalSettings.RotationAngle.CurrentValue,
+                vm.PicViewer.RotationAngle.CurrentValue,
                 null,
                 false,
                 false,
@@ -105,7 +105,7 @@ public static class FileSaverHelper
                                 height: null,
                                 quality,
                                 ext,
-                                vm.GlobalSettings.RotationAngle.CurrentValue);
+                                vm.PicViewer.RotationAngle.CurrentValue);
                         }
                     
                         break;

+ 1 - 1
src/PicView.Avalonia/ImageTransformations/Rotation/RotationNavigation.cs

@@ -26,7 +26,7 @@ public static class RotationNavigation
     public static async Task RotateTo(MainViewModel? vm, int angle)
     {
         await Dispatcher.UIThread.InvokeAsync(() => { vm.ImageViewer.Rotate(angle); });
-        vm.GlobalSettings.RotationAngle.Value = angle;
+        vm.PicViewer.RotationAngle.Value = angle;
         await WindowResizing.SetSizeAsync(vm);
     }
 

+ 7 - 6
src/PicView.Avalonia/ImageTransformations/Rotation/RotationTransformer.cs

@@ -24,10 +24,10 @@ public class RotationTransformer(
             return;
         }
 
-        if (RotationHelper.IsValidRotation(vm.GlobalSettings.RotationAngle.CurrentValue))
+        if (RotationHelper.IsValidRotation(vm.PicViewer.RotationAngle.CurrentValue))
         {
-            var nextAngle = RotationHelper.Rotate(vm.GlobalSettings.RotationAngle.CurrentValue, clockWise);
-            vm.GlobalSettings.RotationAngle.Value = nextAngle switch
+            var nextAngle = RotationHelper.Rotate(vm.PicViewer.RotationAngle.CurrentValue, clockWise);
+            vm.PicViewer.RotationAngle.Value = nextAngle switch
             {
                 360 => 0,
                 -90 => 270,
@@ -36,10 +36,11 @@ public class RotationTransformer(
         }
         else
         {
-            vm.GlobalSettings.RotationAngle.Value = RotationHelper.NextRotationAngle(vm.GlobalSettings.RotationAngle.CurrentValue, true);
+            vm.PicViewer.RotationAngle.Value =
+                RotationHelper.NextRotationAngle(vm.PicViewer.RotationAngle.CurrentValue, true);
         }
 
-        SetImageLayoutTransform(new RotateTransform(vm.GlobalSettings.RotationAngle.CurrentValue));
+        SetImageLayoutTransform(new RotateTransform(vm.PicViewer.RotationAngle.CurrentValue));
         WindowResizing.SetSize(vm);
         mainImage.InvalidateVisual();
     }
@@ -104,7 +105,7 @@ public class RotationTransformer(
         }
 
         vm.PicViewer.ScaleX.Value = scaleX;
-        vm.GlobalSettings.RotationAngle.Value = rotationAngle;
+        vm.PicViewer.RotationAngle.Value = rotationAngle;
         imageLayoutTransformControl.RenderTransform = new ScaleTransform(vm.PicViewer.ScaleX.CurrentValue, 1);
         imageLayoutTransformControl.LayoutTransform = new RotateTransform(rotationAngle);
 

+ 0 - 407
src/PicView.Avalonia/ImageTransformations/Zoom.cs

@@ -1,407 +0,0 @@
-using Avalonia;
-using Avalonia.Animation;
-using Avalonia.Controls;
-using Avalonia.Input;
-using Avalonia.Media;
-using Avalonia.Threading;
-using PicView.Avalonia.UI;
-using PicView.Avalonia.ViewModels;
-using ImageViewer = PicView.Avalonia.Views.UC.ImageViewer;
-using Point = Avalonia.Point;
-
-namespace PicView.Avalonia.ImageTransformations;
-
-public class Zoom
-{
-    private bool _captured;
-    
-    private Point _origin;
-    private Point _start;
-    
-    private TranslateTransform? _translateTransform;
-    private ScaleTransform? _scaleTransform;
-
-    /// <summary>
-    /// Provides zoom functionality for UI elements, supporting both zoom-in and zoom-out operations.
-    /// Manages transformations such as scaling and translating for visual adjustments.
-    /// </summary>
-    public Zoom(Border border)
-    {
-        InitializeZoom(border);
-    }
-
-    /// Indicates whether the current zoom level is applied or not.
-    /// This property will return true if the zoom level is active and differs from the default state (non-zoomed).
-    /// When the zoom level is reset to default, the property will return false.
-    public bool IsZoomed { get; private set; }
-
-
-    private void InitializeZoom(Border border)
-    {
-        border.RenderTransform = new TransformGroup
-        {
-            Children =
-            [
-                new ScaleTransform(),
-                new TranslateTransform()
-            ]
-        };
-
-        _scaleTransform = (ScaleTransform)((TransformGroup)border.RenderTransform)
-            .Children.First(tr => tr is ScaleTransform);
-
-        _translateTransform = (TranslateTransform)((TransformGroup)border.RenderTransform)
-            .Children.First(tr => tr is TranslateTransform);
-
-        border.RenderTransformOrigin = new RelativePoint(0, 0, RelativeUnit.Relative);
-    }
-
-    /// <summary>
-    /// Zooms into the content within the specified parent control, scaling the view while retaining proper alignment.
-    /// </summary>
-    /// <param name="e">The pointer wheel event arguments containing the zoom gesture data.</param>
-    /// <param name="parent">The parent control acting as the container for the content being zoomed.</param>
-    /// <param name="content">The content control subjected to the zoom operation.</param>
-    /// <param name="vm">The main view model providing application state and behavior context.</param>
-    public void ZoomIn(PointerWheelEventArgs e, Control parent, Control content, MainViewModel vm) =>
-        HandlePointerWheelZoom(e, true, parent, content, vm);
-
-    /// <summary>
-    /// Zooms out the content within the specified parent control, scaling the view while retaining proper alignment.
-    /// </summary>
-    /// <param name="e">The pointer wheel event arguments containing the zoom gesture data.</param>
-    /// <param name="parent">The parent control acting as the container for the content being zoomed.</param>
-    /// <param name="content">The content control subjected to the zoom operation.</param>
-    /// <param name="vm">The main view model providing application state and behavior context.</param>
-    public void ZoomOut(PointerWheelEventArgs e, Control parent, Control content, MainViewModel vm) =>
-        HandlePointerWheelZoom(e, false, parent, content, vm);
-
-    /// <summary>
-    /// Zooms into the image using the current starting point and updates the application state.
-    /// </summary>
-    /// <param name="vm">The view model containing the application state and transformation details.</param>
-    public void ZoomIn(Control parent, Control content, MainViewModel vm) =>
-        ZoomTo(GetRelativePosition(parent, content), true, 0.3, vm);
-
-    /// <summary>
-    /// Zooms out the image using the current starting point and updates the application state.
-    /// </summary>
-    /// <param name="vm">The view model containing the application state and transformation details.</param>
-    public void ZoomOut(Control parent, Control content, MainViewModel vm) =>
-        ZoomTo(GetRelativePosition(parent, content), false, 0.3, vm);
-
-    private static Point GetRelativePosition(Control parent, Control content)
-    {
-        // Get center of the ImageViewer control
-        var centerX = parent.Bounds.Width / 2;
-        var centerY = parent.Bounds.Height / 2;
-
-        // Convert to MainImage's coordinate space
-        return parent.TranslatePoint(new Point(centerX, centerY), content)
-               ?? new Point(content.Bounds.Width / 2, content.Bounds.Height / 2);
-    }
-
-    private void HandlePointerWheelZoom(PointerWheelEventArgs e, bool isZoomIn, Control parent, Control content,
-        MainViewModel vm)
-    {
-        var relativePosition = !content.IsPointerOver ? GetRelativePosition(parent, content) : e.GetPosition(content);
-        ZoomTo(relativePosition, isZoomIn, Settings.Zoom.ZoomSpeed, vm);
-    }
-
-    /// <summary>
-    /// Adjust the zoom level at a specified point, either zooming in or out, based on the provided parameters.
-    /// </summary>
-    /// <param name="point">The reference point where the zooming action will be centered.</param>
-    /// <param name="isZoomIn">Determines whether to zoom in (true) or zoom out (false).</param>
-    /// <param name="vm">The main view model containing the application's state and settings.</param>
-    public void ZoomTo(Point point, bool isZoomIn, double zoomSpeed, MainViewModel vm)
-    {
-        if (_scaleTransform == null || _translateTransform == null)
-        {
-            return;
-        }
-
-        var currentZoom = _scaleTransform.ScaleX;
-
-        switch (currentZoom)
-        {
-            // Increase speed based on the current zoom level
-            case > 15 when isZoomIn:
-                return;
-
-            case > 4:
-                zoomSpeed += 1;
-                break;
-
-            case > 3.2:
-                zoomSpeed += 0.8;
-                break;
-
-            case > 1.6:
-                zoomSpeed += 0.5;
-                break;
-        }
-
-        if (!isZoomIn)
-        {
-            zoomSpeed = -zoomSpeed;
-        }
-
-        currentZoom += zoomSpeed;
-        currentZoom = Math.Max(0.09, currentZoom); // Fix for zooming out too much
-        if (Settings.Zoom.AvoidZoomingOut && currentZoom < 1.0)
-        {
-            ResetZoom(true, vm);
-        }
-        else
-        {
-            if (currentZoom is > 0.95 and < 1.05 or > 1.0 and < 1.05)
-            {
-                ResetZoom(true, vm);
-            }
-            else
-            {
-                SetZoomAtPoint(point, currentZoom, true, vm);
-            }
-        }
-    }
-
-    private void SetZoomAtPoint(Point point, double zoomValue, bool enableAnimations, MainViewModel vm)
-    {
-        if (_scaleTransform == null || _translateTransform == null)
-        {
-            return;
-        }
-
-        if (enableAnimations)
-        {
-            _scaleTransform.Transitions ??=
-            [
-                new DoubleTransition { Property = ScaleTransform.ScaleXProperty, Duration = TimeSpan.FromSeconds(.25) },
-                new DoubleTransition { Property = ScaleTransform.ScaleYProperty, Duration = TimeSpan.FromSeconds(.25) }
-            ];
-            _translateTransform.Transitions ??=
-            [
-                new DoubleTransition { Property = TranslateTransform.XProperty, Duration = TimeSpan.FromSeconds(.25) },
-                new DoubleTransition { Property = TranslateTransform.YProperty, Duration = TimeSpan.FromSeconds(.25) }
-            ];
-        }
-        else
-        {
-            _scaleTransform.Transitions = null;
-            _translateTransform.Transitions = null;
-        }
-
-        var absoluteX = point.X * _scaleTransform.ScaleX + _translateTransform.X;
-        var absoluteY = point.Y * _scaleTransform.ScaleY + _translateTransform.Y;
-
-        var newTranslateValueX = Math.Abs(zoomValue - 1) > .2 ? absoluteX - point.X * zoomValue : 0;
-        var newTranslateValueY = Math.Abs(zoomValue - 1) > .2 ? absoluteY - point.Y * zoomValue : 0;
-
-        _scaleTransform.ScaleX = zoomValue;
-        _scaleTransform.ScaleY = zoomValue;
-        _translateTransform.X = newTranslateValueX;
-        _translateTransform.Y = newTranslateValueY;
-
-        IsZoomed = zoomValue != 0;
-        if (vm is null)
-        {
-            return;
-        }
-
-        vm.GlobalSettings.ZoomValue.Value = zoomValue;
-        if (!IsZoomed)
-        {
-            return;
-        }
-
-        TitleManager.SetTitle(vm);
-        _ = TooltipHelper.ShowTooltipMessageContinuallyAsync($"{Math.Floor(zoomValue * 100)}%", true, TimeSpan.FromSeconds(1));
-    }
-
-    /// <summary>
-    /// Resets the zoom to its default state.
-    /// </summary>
-    /// <param name="enableAnimations">Specifies whether animations should be applied during the reset.</param>
-    /// <param name="vm">The view model associated with the main application, used for managing zoom state and title updates.</param>
-    public void ResetZoom(bool enableAnimations, MainViewModel vm)
-    {
-        if (_scaleTransform == null || _translateTransform == null)
-        {
-            return;
-        }
-
-        Dispatcher.UIThread.InvokeAsync(() =>
-        {
-            if (enableAnimations)
-            {
-                _scaleTransform.Transitions ??=
-                [
-                    new DoubleTransition
-                        { Property = ScaleTransform.ScaleXProperty, Duration = TimeSpan.FromSeconds(.25) },
-                    new DoubleTransition
-                        { Property = ScaleTransform.ScaleYProperty, Duration = TimeSpan.FromSeconds(.25) }
-                ];
-                _translateTransform.Transitions ??=
-                [
-                    new DoubleTransition
-                        { Property = TranslateTransform.XProperty, Duration = TimeSpan.FromSeconds(.25) },
-                    new DoubleTransition
-                        { Property = TranslateTransform.YProperty, Duration = TimeSpan.FromSeconds(.25) }
-                ];
-            }
-            else
-            {
-                _scaleTransform.Transitions = null;
-                _translateTransform.Transitions = null;
-            }
-
-            _scaleTransform.ScaleX = 1;
-            _scaleTransform.ScaleY = 1;
-            _translateTransform.X = 0;
-            _translateTransform.Y = 0;
-        }, DispatcherPriority.Send);
-
-        IsZoomed = false;
-
-        if (vm is null)
-        {
-            return;
-        }
-
-        vm.GlobalSettings.ZoomValue.Value = 1;
-        TitleManager.SetTitle(vm);
-    }
-
-    /// <summary>
-    /// Captures the current pointer position and initializes the origin point for zooming transformations.
-    /// </summary>
-    /// <param name="e">The pointer event arguments providing data about the pointer position and device state.</param>
-    public void Capture(PointerEventArgs e)
-    {
-        if (_captured)
-        {
-            return;
-        }
-
-        if (_scaleTransform == null || _translateTransform == null)
-        {
-            return;
-        }
-
-        var mainView = UIHelper.GetMainView;
-
-        var point = e.GetCurrentPoint(mainView);
-        var x = point.Position.X;
-        var y = point.Position.Y;
-        _start = new Point(x, y);
-        _origin = new Point(_translateTransform.X, _translateTransform.Y);
-        _captured = true;
-    }
-
-    /// <summary>
-    /// Handles panning of the zoomed image by adjusting translation transforms based on pointer movement.
-    /// </summary>
-    /// <param name="e">Pointer event arguments containing details about the pointer's position and state.</param>
-    /// <param name="imageViewer">The image viewer instance on which the panning operation is performed.</param>
-    public void Pan(PointerEventArgs e, ImageViewer imageViewer)
-    {
-        if (!_captured || _scaleTransform == null || !IsZoomed)
-        {
-            return;
-        }
-
-        if (e.KeyModifiers == KeyModifiers.Shift)
-        {
-            _captured = false;
-            return;
-        }
-
-        var dragMousePosition = _start - e.GetPosition(imageViewer);
-
-        // Get the current rotation angle from the ViewModel
-        var vm = imageViewer.DataContext as MainViewModel;
-        var rotationAngle = vm?.GlobalSettings.RotationAngle.CurrentValue ?? 0;
-
-        // Apply rotation transformation to the mouse movement
-        var rotationRadians = rotationAngle * Math.PI / 180.0;
-        var cos = Math.Cos(rotationRadians);
-        var sin = Math.Sin(rotationRadians);
-
-        double rotatedX;
-        double rotatedY;
-
-        switch (rotationAngle)
-        {
-            case 90:
-            case 270:
-                rotatedX = -(dragMousePosition.X * cos - dragMousePosition.Y * sin);
-                rotatedY = -(dragMousePosition.X * sin + dragMousePosition.Y * cos);
-                break;
-            default:
-                rotatedX = dragMousePosition.X * cos - dragMousePosition.Y * sin;
-                rotatedY = dragMousePosition.X * sin + dragMousePosition.Y * cos;
-                break;
-        }
-
-        var newXproperty = _origin.X - rotatedX;
-        var newYproperty = _origin.Y - rotatedY;
-
-        // #185
-        if (Settings.WindowProperties.Fullscreen || Settings.WindowProperties.Maximized ||
-            !Settings.WindowProperties.AutoFit)
-        {
-            // TODO: figure out how to pan when not auto fitting window while keeping it in bounds
-            _translateTransform.Transitions = null;
-            _translateTransform.X = newXproperty;
-            _translateTransform.Y = newYproperty;
-            e.Handled = true;
-            return;
-        }
-
-        var actualScrollWidth = imageViewer.ImageScrollViewer.Bounds.Width;
-        var actualBorderWidth = imageViewer.MainBorder.Bounds.Width;
-        var actualScrollHeight = imageViewer.ImageScrollViewer.Bounds.Height;
-        var actualBorderHeight = imageViewer.MainBorder.Bounds.Height;
-
-        var isXOutOfBorder = actualScrollWidth < actualBorderWidth * _scaleTransform.ScaleX;
-        var isYOutOfBorder = actualScrollHeight < actualBorderHeight * _scaleTransform.ScaleY;
-        var maxX = actualScrollWidth - actualBorderWidth * _scaleTransform.ScaleX;
-        var maxY = actualScrollHeight - actualBorderHeight * _scaleTransform.ScaleY;
-
-        // Clamp X translation
-        if ((isXOutOfBorder && newXproperty < maxX) || (!isXOutOfBorder && newXproperty > maxX))
-        {
-            newXproperty = maxX;
-        }
-
-        if ((isXOutOfBorder && newXproperty > 0) || (!isXOutOfBorder && newXproperty < 0))
-        {
-            newXproperty = 0;
-        }
-
-        // Clamp Y translation
-        if ((isYOutOfBorder && newYproperty < maxY) || (!isYOutOfBorder && newYproperty > maxY))
-        {
-            newYproperty = maxY;
-        }
-
-        if ((isYOutOfBorder && newYproperty > 0) || (!isYOutOfBorder && newYproperty < 0))
-        {
-            newYproperty = 0;
-        }
-
-        _translateTransform.Transitions = null;
-        _translateTransform.X = newXproperty;
-        _translateTransform.Y = newYproperty;
-        e.Handled = true;
-    }
-
-    /// <summary>
-    /// Releases any current state of capturing associated with zoom or panning functionality.
-    /// </summary>
-    public void Release()
-    {
-        _captured = false;
-    }
-}

+ 4 - 4
src/PicView.Avalonia/Navigation/UpdateImage.cs

@@ -171,7 +171,7 @@ public static class UpdateImage
         {
             WindowResizing.SetSize(preLoadValue.ImageModel.PixelWidth, preLoadValue.ImageModel.PixelHeight,
                     nextPreloadValue?.ImageModel?.PixelWidth ?? 0, nextPreloadValue?.ImageModel?.PixelHeight ?? 0,
-                    vm.GlobalSettings.RotationAngle.CurrentValue, vm);
+                    vm.PicViewer.RotationAngle.CurrentValue, vm);
         }
 
     }
@@ -227,10 +227,10 @@ public static class UpdateImage
             }
             
             WindowResizing.SetSize(width, height, 0, 0, 0, vm);
-            
-            if (vm.GlobalSettings.RotationAngle.CurrentValue != 0)
+
+            if (vm.PicViewer.RotationAngle.CurrentValue != 0)
             {
-                vm.ImageViewer.Rotate(vm.GlobalSettings.RotationAngle.CurrentValue);
+                vm.ImageViewer.Rotate(vm.PicViewer.RotationAngle.CurrentValue);
             }
         }, DispatcherPriority.Render);
         

+ 1 - 1
src/PicView.Avalonia/SettingsManagement/SettingsUpdater.cs

@@ -396,7 +396,7 @@ public static class SettingsUpdater
             await Dispatcher.UIThread.InvokeAsync(() =>
             {
                 WindowResizing.SetSize(vm.PicViewer.ImageWidth.CurrentValue, vm.PicViewer.ImageHeight.CurrentValue, preloadValue.ImageModel.PixelWidth,
-                    preloadValue.ImageModel.PixelHeight, vm.GlobalSettings.RotationAngle.CurrentValue, vm);
+                    preloadValue.ImageModel.PixelHeight, vm.PicViewer.RotationAngle.CurrentValue, vm);
                 TitleManager.SetSideBySideTitle(vm, imageModel1, imageModel2);
             });
         }

+ 3 - 2
src/PicView.Avalonia/StartUp/QuickLoad.cs

@@ -165,7 +165,8 @@ public static class QuickLoad
         await Dispatcher.UIThread.InvokeAsync(() =>
         {
             vm.ImageViewer.SetTransform(ExifOrientationHelper.GetImageOrientation(magickImage), magickImage.Format);
-            WindowResizing.SetSize(magickImage.Width, magickImage.Height, secondaryPreloadValue.ImageModel.PixelWidth, secondaryPreloadValue.ImageModel.PixelHeight, vm.GlobalSettings.RotationAngle.CurrentValue, vm);
+            WindowResizing.SetSize(magickImage.Width, magickImage.Height, secondaryPreloadValue.ImageModel.PixelWidth,
+                secondaryPreloadValue.ImageModel.PixelHeight, vm.PicViewer.RotationAngle.CurrentValue, vm);
         }, DispatcherPriority.Send);
         if (Settings.WindowProperties.AutoFit && RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
         {
@@ -193,7 +194,7 @@ public static class QuickLoad
         
         vm.PicViewer.ImageSource.Value = imageModel.Image;
         vm.PicViewer.ImageType.Value = imageModel.ImageType;
-        vm.GlobalSettings.RotationAngle.Value = 0;
+        vm.PicViewer.RotationAngle.Value = 0;
         vm.PicViewer.PixelWidth.Value = imageModel.PixelWidth;
         vm.PicViewer.PixelHeight.Value = imageModel.PixelHeight;
         vm.PicViewer.Format.Value = imageModel.Format;

+ 4 - 3
src/PicView.Avalonia/UI/TitleManager.cs

@@ -42,7 +42,8 @@ public static class TitleManager
             }
 
             var singleImageWindowTitles =
-                ImageTitleFormatter.GenerateTitleForSingleImage(pWidth, pHeight, title, vm.GlobalSettings.RotationAngle.CurrentValue);
+                ImageTitleFormatter.GenerateTitleForSingleImage(pWidth, pHeight, title,
+                    vm.PicViewer.RotationAngle.CurrentValue);
             vm.PicViewer.WindowTitle.Value = singleImageWindowTitles.BaseTitle;
             vm.PicViewer.Title.Value = singleImageWindowTitles.TitleWithAppName;
             vm.PicViewer.TitleTooltip.Value = singleImageWindowTitles.TitleWithAppName;
@@ -238,10 +239,10 @@ public static class TitleManager
         
         var firstWindowTitles = ImageTitleFormatter.GenerateTitleStrings(imageModel1.PixelWidth,
             imageModel1.PixelHeight, NavigationManager.GetCurrentIndex,
-            imageModel1.FileInfo, vm.GlobalSettings.RotationAngle.CurrentValue, NavigationManager.GetCollection);
+            imageModel1.FileInfo, vm.PicViewer.RotationAngle.CurrentValue, NavigationManager.GetCollection);
         var secondWindowTitles = ImageTitleFormatter.GenerateTitleStrings(imageModel2.PixelWidth,
             imageModel2.PixelHeight, NavigationManager.GetNextIndex,
-            imageModel2.FileInfo, vm.GlobalSettings.RotationAngle.CurrentValue, NavigationManager.GetCollection);
+            imageModel2.FileInfo, vm.PicViewer.RotationAngle.CurrentValue, NavigationManager.GetCollection);
         var windowTitle = $"{firstWindowTitles.BaseTitle} \u21dc || \u21dd {secondWindowTitles.BaseTitle} - PicView";
         var title = $"{firstWindowTitles.BaseTitle} \u21dc || \u21dd  {secondWindowTitles.BaseTitle}";
         var titleTooltip = $"{firstWindowTitles.FilePathTitle} \u21dc || \u21dd  {secondWindowTitles.FilePathTitle}";

+ 1 - 1
src/PicView.Avalonia/Views/Main/SettingsView.axaml.cs

@@ -95,7 +95,7 @@ public partial class SettingsView : UserControl
             {
                 Settings.WindowProperties.Margin = x;
                 WindowResizing.SetSize(vm.PicViewer.PixelWidth.CurrentValue, vm.PicViewer.PixelHeight.CurrentValue, 0,
-                    0, vm.GlobalSettings.RotationAngle.CurrentValue, vm);
+                    0, vm.PicViewer.RotationAngle.CurrentValue, vm);
                 WindowFunctions.CenterWindowOnScreen();
             }).AddTo(_marginSubscription);
     }

+ 26 - 30
src/PicView.Avalonia/Views/UC/ImageViewer.axaml

@@ -1,7 +1,6 @@
 <UserControl
     Background="{CompiledBinding MainWindow.ImageBackground.Value,
                                  Mode=OneWay}"
-    PointerPressed="InputElement_OnPointerPressed"
     mc:Ignorable="d"
     x:Class="PicView.Avalonia.Views.UC.ImageViewer"
     x:DataType="vm:MainViewModel"
@@ -15,39 +14,36 @@
         <vm:MainViewModel />
     </Design.DataContext>
     <LayoutTransformControl x:Name="ImageLayoutTransformControl">
-        <customControls:AutoScrollViewer
-            Focusable="False"
-            Height="{CompiledBinding PicViewer.ScrollViewerHeight.Value,
+        <customControls:ZoomPanControl
+            Height="{CompiledBinding PicViewer.ImageHeight.Value,
                                      Mode=OneWay}"
-            ScrollChanged="ImageScrollViewer_OnScrollChanged"
-            Theme="{StaticResource Main}"
-            VerticalScrollBarVisibility="{CompiledBinding MainWindow.ToggleScrollBarVisibility.Value}"
-            Width="{CompiledBinding PicViewer.ScrollViewerWidth.Value,
+            Width="{CompiledBinding PicViewer.ImageWidth.Value,
                                     Mode=OneWay}"
-            x:Name="ImageScrollViewer">
-            <Border
-                Background="{CompiledBinding MainWindow.ConstrainedImageBackground.Value,
-                                             Mode=OneWay}"
-                Height="{CompiledBinding PicViewer.ImageHeight.Value,
+            x:Name="ZoomPanControl">
+            <customControls:AutoScrollViewer
+                Focusable="False"
+                Height="{CompiledBinding PicViewer.ScrollViewerHeight.Value,
                                          Mode=OneWay}"
-                Width="{CompiledBinding PicViewer.ImageWidth.Value,
+                Theme="{StaticResource Main}"
+                VerticalScrollBarVisibility="{CompiledBinding MainWindow.ToggleScrollBarVisibility.Value}"
+                Width="{CompiledBinding PicViewer.ScrollViewerWidth.Value,
                                         Mode=OneWay}"
-                x:Name="MainBorder">
-                <customControls:PicBox
-                    ImageType="{CompiledBinding PicViewer.ImageType.Value,
-                                                Mode=OneWay}"
-                    PointerMoved="MainImage_OnPointerMoved"
-                    PointerPressed="MainImage_OnPointerPressed"
-                    PointerReleased="MainImage_OnPointerReleased"
-                    SecondaryImageWidth="{CompiledBinding PicViewer.SecondaryImageWidth.Value,
+                x:Name="ImageScrollViewer">
+                <Border Background="{CompiledBinding MainWindow.ConstrainedImageBackground.Value, Mode=OneWay}"
+                        x:Name="MainBorder">
+                    <customControls:PicBox
+                        ImageType="{CompiledBinding PicViewer.ImageType.Value,
+                                                    Mode=OneWay}"
+                        SecondaryImageWidth="{CompiledBinding PicViewer.SecondaryImageWidth.Value,
+                                                              Mode=OneWay}"
+                        SecondarySource="{CompiledBinding PicViewer.SecondaryImageSource.Value,
                                                           Mode=OneWay}"
-                    SecondarySource="{CompiledBinding PicViewer.SecondaryImageSource.Value,
-                                                      Mode=OneWay}"
-                    Source="{CompiledBinding PicViewer.ImageSource.Value,
-                                             Mode=OneWay}"
-                    UseLayoutRounding="True"
-                    x:Name="MainImage" />
-            </Border>
-        </customControls:AutoScrollViewer>
+                        Source="{CompiledBinding PicViewer.ImageSource.Value,
+                                                 Mode=OneWay}"
+                        UseLayoutRounding="True"
+                        x:Name="MainImage" />
+                </Border>
+            </customControls:AutoScrollViewer>
+        </customControls:ZoomPanControl>
     </LayoutTransformControl>
 </UserControl>

+ 19 - 79
src/PicView.Avalonia/Views/UC/ImageViewer.axaml.cs

@@ -1,4 +1,3 @@
-using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Input;
 using Avalonia.Interactivity;
@@ -12,51 +11,46 @@ using PicView.Core.Exif;
 
 namespace PicView.Avalonia.Views.UC;
 
+
 public partial class ImageViewer : UserControl
 {
     private RotationTransformer? _imageTransformer;
-    private Zoom? _zoom;
-
+    
     public ImageViewer()
     {
         InitializeComponent();
+        Loaded += OnLoaded;
+    }
+
+    private void OnLoaded(object? sender, RoutedEventArgs e)
+    {
         InitializeImageTransformer();
         AddHandler(PointerWheelChangedEvent, PreviewOnPointerWheelChanged, RoutingStrategies.Tunnel);
         AddHandler(Gestures.PointerTouchPadGestureMagnifyEvent, TouchMagnifyEvent, RoutingStrategies.Bubble);
         AddHandler(Gestures.PinchEvent, TouchMagnifyEvent, RoutingStrategies.Bubble);
-    }
-
-    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
-    {
-        base.OnAttachedToVisualTree(e);
         ImageControlHelper.TriggerScalingModeUpdate(MainImage, false);
-        InitializeZoom();
         InitializeMouseInputHelper();
-        LostFocus += OnLostFocus;
     }
 
-    private void OnLostFocus(object? sender, EventArgs e) => _zoom?.Release();
-
     public void TriggerScalingModeUpdate(bool invalidate) =>
         ImageControlHelper.TriggerScalingModeUpdate(MainImage, invalidate);
 
     private void TouchMagnifyEvent(object? sender, PointerDeltaEventArgs e) =>
-        _zoom?.ZoomTo(e.GetPosition(this), e.Delta.X > 0, Settings.Zoom.ZoomSpeed, DataContext as MainViewModel);
+        ZoomPanControl.ZoomWithPointerWheel(e);
 
     public static async Task PreviewOnPointerWheelChanged(object? sender, PointerWheelEventArgs e) =>
         await MouseShortcuts.HandlePointerWheelChanged(e);
 
-    private void InitializeZoom() => _zoom = new Zoom(MainBorder);
-
     public void InitializeImageTransformer()
     {
+        ZoomPanControl.Initialize();
         _imageTransformer = new RotationTransformer(
             ImageLayoutTransformControl,
             MainImage,
             () => DataContext,
             () =>
             {
-                if (_zoom?.IsZoomed == true)
+                if (ZoomPanControl?.ZoomLevel != 0)
                 {
                     ResetZoom(false);
                 }
@@ -70,86 +64,32 @@ public partial class ImageViewer : UserControl
             async e => { await Dispatcher.UIThread.InvokeAsync(() => { ZoomOut(e); }); });
 
     #region Zoom
-
     /// <inheritdoc cref="Zoom.ZoomIn(MainViewModel)"/>
     private void ZoomIn(PointerWheelEventArgs e) =>
-        _zoom?.ZoomIn(e, this, MainImage, DataContext as MainViewModel);
-    
+        ZoomPanControl.ZoomWithPointerWheel(e);
+
     /// <inheritdoc cref="Zoom.ZoomOut(MainViewModel)"/>
     private void ZoomOut(PointerWheelEventArgs e) =>
-        _zoom?.ZoomOut(e, this, MainImage, DataContext as MainViewModel);
+        ZoomPanControl.ZoomWithPointerWheel(e);
 
     /// <inheritdoc cref="Zoom.ZoomIn(MainViewModel)"/>
-    public void ZoomIn() => _zoom?.ZoomIn(this, MainImage,DataContext as MainViewModel);
+    public void ZoomIn() =>
+        ZoomPanControl.ZoomIn();
 
     /// <inheritdoc cref="Zoom.ZoomOut(MainViewModel)"/>
-    public void ZoomOut() => _zoom?.ZoomOut(this, MainImage, DataContext as MainViewModel);
+    public void ZoomOut() =>
+        ZoomPanControl.ZoomOut();
+
     /// <inheritdoc cref="Zoom.ResetZoom(bool, MainViewModel)"/>
     public void ResetZoom(bool enableAnimations = true) =>
-        _zoom?.ResetZoom(enableAnimations, DataContext as MainViewModel);
-
+        ZoomPanControl.ResetZoom(enableAnimations, false);
     #endregion
 
     #region Image Transformation
-
     public void Rotate(bool clockWise) => _imageTransformer?.Rotate(clockWise);
-
     public void Rotate(double angle) => _imageTransformer?.Rotate(angle);
-
     public void Flip(bool animate) => _imageTransformer?.Flip(animate);
-
     public void SetTransform(ExifOrientation? orientation, MagickFormat? format, bool reset = true) =>
         _imageTransformer?.SetTransform(orientation, format, reset);
-
-    #endregion
-
-    #region Events
-
-    private void ImageScrollViewer_OnScrollChanged(object? sender, ScrollChangedEventArgs e)
-    {
-        e.Handled = true;
-    }
-
-    private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e)
-    {
-        if (e.ClickCount == 2)
-        {
-            ResetZoom();
-        }
-    }
-
-    private void MainImage_OnPointerPressed(object? sender, PointerPressedEventArgs e)
-    {
-        if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
-        {
-            return;
-        }
-
-        if (e.ClickCount == 2)
-        {
-            ResetZoom();
-        }
-        else
-        {
-            Pressed(e);
-        }
-    }
-
-    private void MainImage_OnPointerMoved(object? sender, PointerEventArgs e) =>
-        _zoom?.Pan(e, this);
-
-    private void Pressed(PointerEventArgs e)
-    {
-        if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
-        {
-            return;
-        }
-
-        _zoom?.Capture(e);
-    }
-
-    private void MainImage_OnPointerReleased(object? sender, PointerReleasedEventArgs e) =>
-        _zoom?.Release();
-
     #endregion
 }

+ 1 - 1
src/PicView.Avalonia/Views/UC/RotationContextMenu.axaml.cs

@@ -31,7 +31,7 @@ public partial class RotationContextMenu : ContextMenu
         Rotation90Item.IsChecked = false;
         Rotation180Item.IsChecked = false;
         Rotation270Item.IsChecked = false;
-        switch (vm.GlobalSettings.RotationAngle.CurrentValue)
+        switch (vm.PicViewer.RotationAngle.CurrentValue)
         {
             case 0:
                 Rotation0Item.IsChecked = true;

+ 2 - 1
src/PicView.Avalonia/Views/UC/StartUpMenu.axaml.cs

@@ -126,7 +126,8 @@ public partial class StartUpMenu : UserControl
                 break;
         }
 
-        var titleMaxWidth = ImageSizeCalculationHelper.GetTitleMaxWidth(vm.GlobalSettings.RotationAngle.CurrentValue, width, height,
+        var titleMaxWidth = ImageSizeCalculationHelper.GetTitleMaxWidth(vm.PicViewer.RotationAngle.CurrentValue, width,
+            height,
             desktop.MainWindow.MinWidth, desktop.MainWindow.MinHeight, vm.PlatformWindowService.CombinedTitleButtonsWidth,
             desktop.MainWindow.Width);
 

+ 21 - 0
src/PicView.Avalonia/Views/UC/ZoomPreviewWindow.axaml

@@ -0,0 +1,21 @@
+<Window
+    BorderBrush="{DynamicResource MainBorderColor}"
+    BorderThickness="1"
+    CanResize="False"
+    SystemDecorations="None"
+    Title="ZoomPreviewWindow"
+    Topmost="True"
+    x:Class="PicView.Avalonia.Views.UC.ZoomPreviewWindow"
+    xmlns="https://github.com/avaloniaui"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+    <Panel>
+        <Image Stretch="Uniform" x:Name="OverlayImage" />
+        <Canvas
+            IsHitTestVisible="False"
+            PointerCaptureLost="OverlayCanvas_OnPointerCaptureLost"
+            PointerMoved="OverlayCanvas_OnPointerMoved"
+            PointerPressed="OverlayCanvas_OnPointerPressed"
+            PointerReleased="OverlayCanvas_OnPointerReleased"
+            x:Name="OverlayCanvas" />
+    </Panel>
+</Window>

+ 28 - 0
src/PicView.Avalonia/Views/UC/ZoomPreviewWindow.axaml.cs

@@ -0,0 +1,28 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace PicView.Avalonia.Views.UC;
+
+public partial class ZoomPreviewWindow : Window
+{
+    private void OverlayCanvas_OnPointerPressed(object? sender, PointerPressedEventArgs e)
+    {
+    }
+
+    private void UpdateViewportRect()
+    {
+    }
+
+
+    private void OverlayCanvas_OnPointerMoved(object? sender, PointerEventArgs e)
+    {
+    }
+
+    private void OverlayCanvas_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
+    {
+    }
+
+    private void OverlayCanvas_OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
+    {
+    }
+}

+ 4 - 4
src/PicView.Avalonia/WindowBehavior/WindowResizing.cs

@@ -140,7 +140,7 @@ public static class WindowResizing
     }
 
     public static void SetSize(double width, double height, MainViewModel vm)
-        => SetSize(width, height, 0, 0, vm.GlobalSettings.RotationAngle.CurrentValue, vm);
+        => SetSize(width, height, 0, 0, vm.PicViewer.RotationAngle.CurrentValue, vm);
 
     public static void SetSize(double width, double height, double secondWidth, double secondHeight, double rotation,
         MainViewModel vm)
@@ -183,7 +183,7 @@ public static class WindowResizing
             else
             {
                 var scrollbarSize = Settings.Zoom.ScrollEnabled ? SizeDefaults.ScrollbarSize : 0;
-                vm.PicViewer.GalleryWidth.Value = vm.GlobalSettings.RotationAngle.CurrentValue is 90 or 270
+                vm.PicViewer.GalleryWidth.Value = vm.PicViewer.RotationAngle.CurrentValue is 90 or 270
                     ? Math.Max(size.Height + scrollbarSize, SizeDefaults.WindowMinSize + scrollbarSize)
                     : Math.Max(size.Width + scrollbarSize, SizeDefaults.WindowMinSize + scrollbarSize);
             }
@@ -240,7 +240,7 @@ public static class WindowResizing
 
         if (!Settings.ImageScaling.ShowImageSideBySide)
         {
-            return GetSize(firstWidth, firstHeight, 0, 0, vm.GlobalSettings.RotationAngle.CurrentValue, vm);
+            return GetSize(firstWidth, firstHeight, 0, 0, vm.PicViewer.RotationAngle.CurrentValue, vm);
         }
 
         var secondaryPreloadValue = NavigationManager.GetNextPreLoadValue();
@@ -264,7 +264,7 @@ public static class WindowResizing
             secondHeight = 0;
         }
 
-        return GetSize(firstWidth, firstHeight, secondWidth, secondHeight, vm.GlobalSettings.RotationAngle.CurrentValue,
+        return GetSize(firstWidth, firstHeight, secondWidth, secondHeight, vm.PicViewer.RotationAngle.CurrentValue,
             vm);
     }
 

+ 1 - 3
src/PicView.Benchmarks/StringBenchmarks/FileSizeBenchmark.cs

@@ -15,10 +15,8 @@ public class FileSizeBenchmark
     private List<FileInfo>? _fileInfos;
 
     [GlobalSetup]
-    public async ValueTask Setup()
+    public void Setup()
     {
-        await LoadSettingsAsync();
-
         var picturesPath = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
         _fileInfos = new DirectoryInfo(picturesPath)
             .DescendantsAndSelf()

+ 0 - 2
src/PicView.Core/ViewModels/GlobalSettingsViewModel.cs

@@ -19,8 +19,6 @@ public class GlobalSettingsViewModel
     
     public BindableReactiveProperty<bool> IsFileHistoryEnabled { get; } = new(Settings.Navigation.IsFileHistoryEnabled);
 
-    public BindableReactiveProperty<double> RotationAngle { get; } = new(0);
-
     public BindableReactiveProperty<double> ZoomValue { get; } = new();
     
     public BindableReactiveProperty<bool> IsShowingTaskbarProgress { get; } = new(Settings.UIProperties.IsTaskbarProgressEnabled);

+ 2 - 0
src/PicView.Core/ViewModels/PicViewerModel.cs

@@ -36,6 +36,8 @@ public class PicViewerModel : IDisposable
     
     public BindableReactiveProperty<MagickFormat?> Format { get; } = new();
 
+    public BindableReactiveProperty<double> RotationAngle { get; } = new(0);
+
     /// <summary>
     /// The width to scale the image to
     /// </summary>