diff --git a/MagickCrop-Package/MagickCrop-Package.wapproj b/MagickCrop-Package/MagickCrop-Package.wapproj
index 60866bb..f3021f4 100644
--- a/MagickCrop-Package/MagickCrop-Package.wapproj
+++ b/MagickCrop-Package/MagickCrop-Package.wapproj
@@ -165,7 +165,7 @@
-
+
diff --git a/MagickCrop/Controls/AngleMeasurementControl.xaml.cs b/MagickCrop/Controls/AngleMeasurementControl.xaml.cs
index 9fdb058..33ff44a 100644
--- a/MagickCrop/Controls/AngleMeasurementControl.xaml.cs
+++ b/MagickCrop/Controls/AngleMeasurementControl.xaml.cs
@@ -27,6 +27,33 @@ public AngleMeasurementControl()
UpdatePositions();
}
+ public bool IsDragGizmoVisible
+ {
+ get => Point1.Visibility == Visibility.Visible;
+ set
+ {
+ Visibility visibility = value ? Visibility.Visible : Visibility.Collapsed;
+ Point1.Visibility = visibility;
+ VertexPoint.Visibility = visibility;
+ Point3.Visibility = visibility;
+ }
+ }
+
+ public bool IsEndpointCapVisible
+ {
+ set
+ {
+ double size = value ? 6 : 12;
+ Point1.Width = size;
+ Point1.Height = size;
+ VertexPoint.Width = size;
+ VertexPoint.Height = size;
+ Point3.Width = size;
+ Point3.Height = size;
+ UpdatePositions();
+ }
+ }
+
public void InitializePositions(double canvasWidth, double canvasHeight)
{
// Place points at reasonable starting positions
@@ -72,7 +99,7 @@ private void UpdatePositions()
double angle = CalculateAngle();
UpdateAngleArc();
- AngleTextBlock.Text = $"{angle:F1}°";
+ AngleTextBlock.Text = $"{angle:F1}\u00B0";
// Position the measurement text near the vertex
Canvas.SetLeft(MeasurementText, vertexPosition.X + 15);
diff --git a/MagickCrop/Controls/CircleMeasurementControl.xaml.cs b/MagickCrop/Controls/CircleMeasurementControl.xaml.cs
index 29ac3c2..332c6b4 100644
--- a/MagickCrop/Controls/CircleMeasurementControl.xaml.cs
+++ b/MagickCrop/Controls/CircleMeasurementControl.xaml.cs
@@ -46,6 +46,30 @@ public CircleMeasurementControl()
UpdatePositions();
}
+ public bool IsDragGizmoVisible
+ {
+ get => CenterPoint.Visibility == Visibility.Visible;
+ set
+ {
+ Visibility visibility = value ? Visibility.Visible : Visibility.Collapsed;
+ CenterPoint.Visibility = visibility;
+ EdgePoint.Visibility = visibility;
+ }
+ }
+
+ public bool IsEndpointCapVisible
+ {
+ set
+ {
+ double size = value ? 6 : 12;
+ CenterPoint.Width = size;
+ CenterPoint.Height = size;
+ EdgePoint.Width = size;
+ EdgePoint.Height = size;
+ UpdatePositions();
+ }
+ }
+
public void InitializePositions(double canvasWidth, double canvasHeight)
{
center = new Point(canvasWidth * 0.5, canvasHeight * 0.5);
@@ -106,7 +130,7 @@ private void UpdateMeasurementText()
double scaledCircumference = circumference * ScaleFactor;
double scaledArea = area * ScaleFactor * ScaleFactor; // Area scales by factor squared
- CircleTextBlock.Text = $"r: {scaledRadius:N2} {Units}, C: {scaledCircumference:N2} {Units}, A: {scaledArea:N2} {Units}²";
+ CircleTextBlock.Text = $"r: {scaledRadius:N2} {Units}, C: {scaledCircumference:N2} {Units}, A: {scaledArea:N2} {Units}\u00B2";
}
private void MeasurementPoint_MouseDown(object sender, MouseButtonEventArgs e)
diff --git a/MagickCrop/Controls/CornerNavButton.cs b/MagickCrop/Controls/CornerNavButton.cs
new file mode 100644
index 0000000..8df2486
--- /dev/null
+++ b/MagickCrop/Controls/CornerNavButton.cs
@@ -0,0 +1,52 @@
+ο»Ώusing System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Shapes;
+
+namespace MagickCrop.Controls;
+
+///
+/// A small circular button placed beside a transform handle that jumps the viewport to the
+/// neighbouring handle in the polygon. Derives from so it can be styled
+/// implicitly and identified during hit testing.
+///
+public class CornerNavButton : Button
+{
+ private readonly RotateTransform arrowRotation = new();
+
+ public CornerNavButton()
+ {
+ Path arrow = new()
+ {
+ Data = Geometry.Parse("M 0,0 L 6,4.5 L 0,9 Z"),
+ Fill = Brushes.White,
+ Stretch = Stretch.None,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ RenderTransformOrigin = new Point(0.5, 0.5),
+ RenderTransform = arrowRotation,
+ IsHitTestVisible = false,
+ };
+
+ Content = arrow;
+ }
+
+ ///
+ /// The handle this button navigates to.
+ ///
+ public Ellipse? Target { get; set; }
+
+ ///
+ /// The handle this button is anchored beside.
+ ///
+ public Ellipse? Anchor { get; set; }
+
+ ///
+ /// Rotates the arrow glyph so it points at the target handle.
+ ///
+ public double ArrowAngle
+ {
+ get => arrowRotation.Angle;
+ set => arrowRotation.Angle = value;
+ }
+}
diff --git a/MagickCrop/Controls/DistanceMeasurementControl.xaml.cs b/MagickCrop/Controls/DistanceMeasurementControl.xaml.cs
index becf426..c0c6e08 100644
--- a/MagickCrop/Controls/DistanceMeasurementControl.xaml.cs
+++ b/MagickCrop/Controls/DistanceMeasurementControl.xaml.cs
@@ -51,6 +51,30 @@ public DistanceMeasurementControl()
UpdatePositions();
}
+ public bool IsDragGizmoVisible
+ {
+ get => StartPoint.Visibility == Visibility.Visible;
+ set
+ {
+ Visibility visibility = value ? Visibility.Visible : Visibility.Collapsed;
+ StartPoint.Visibility = visibility;
+ EndPoint.Visibility = visibility;
+ }
+ }
+
+ public bool IsEndpointCapVisible
+ {
+ set
+ {
+ double size = value ? 6 : 12;
+ StartPoint.Width = size;
+ StartPoint.Height = size;
+ EndPoint.Width = size;
+ EndPoint.Height = size;
+ UpdatePositions();
+ }
+ }
+
public void InitializePositions(double canvasWidth, double canvasHeight)
{
// Place points at reasonable starting positions
diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml.cs b/MagickCrop/Controls/MarkupShapeControl.xaml.cs
index ca71d83..2bee2fb 100644
--- a/MagickCrop/Controls/MarkupShapeControl.xaml.cs
+++ b/MagickCrop/Controls/MarkupShapeControl.xaml.cs
@@ -54,6 +54,17 @@ public double StrokeThickness
}
}
+ public bool IsDragGizmoVisible
+ {
+ get => Point1Handle.Visibility == Visibility.Visible;
+ set
+ {
+ Visibility visibility = value ? Visibility.Visible : Visibility.Collapsed;
+ Point1Handle.Visibility = visibility;
+ Point2Handle.Visibility = visibility;
+ }
+ }
+
public MarkupShapeControl()
{
InitializeComponent();
diff --git a/MagickCrop/Controls/MiniMap.xaml b/MagickCrop/Controls/MiniMap.xaml
new file mode 100644
index 0000000..dc852e0
--- /dev/null
+++ b/MagickCrop/Controls/MiniMap.xaml
@@ -0,0 +1,49 @@
+ο»Ώ
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MagickCrop/Controls/MiniMap.xaml.cs b/MagickCrop/Controls/MiniMap.xaml.cs
new file mode 100644
index 0000000..c0f89ce
--- /dev/null
+++ b/MagickCrop/Controls/MiniMap.xaml.cs
@@ -0,0 +1,126 @@
+ο»Ώusing System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace MagickCrop.Controls;
+
+///
+/// Shows a thumbnail of the current image with a rectangle indicating which part of it
+/// is currently visible in the canvas viewport. Dragging inside the map pans the canvas.
+///
+public partial class MiniMap : UserControl
+{
+ private const double MaxMapWidth = 168;
+ private const double MaxMapHeight = 140;
+
+ ///
+ /// Raised with a point in canvas coordinates that should be centered in the viewport.
+ ///
+ public event EventHandler? ViewportCenterRequested;
+
+ private double mapScale = 1;
+ private bool isDragging;
+
+ public MiniMap()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Updates the map contents.
+ ///
+ /// The image currently shown on the canvas.
+ /// The size of the image in canvas coordinates.
+ /// The visible canvas region in canvas coordinates.
+ /// when the map has valid content to display.
+ public bool UpdateMap(ImageSource? source, Size imageCanvasSize, Rect viewportInCanvas)
+ {
+ if (source is null || imageCanvasSize.Width <= 0 || imageCanvasSize.Height <= 0)
+ {
+ MiniImage.Source = null;
+ return false;
+ }
+
+ if (!ReferenceEquals(MiniImage.Source, source))
+ MiniImage.Source = source;
+
+ mapScale = Math.Min(MaxMapWidth / imageCanvasSize.Width, MaxMapHeight / imageCanvasSize.Height);
+
+ double mapWidth = Math.Max(1, imageCanvasSize.Width * mapScale);
+ double mapHeight = Math.Max(1, imageCanvasSize.Height * mapScale);
+ MapHost.Width = mapWidth;
+ MapHost.Height = mapHeight;
+
+ double left = Math.Clamp(viewportInCanvas.Left * mapScale, 0, mapWidth);
+ double top = Math.Clamp(viewportInCanvas.Top * mapScale, 0, mapHeight);
+ double right = Math.Clamp(viewportInCanvas.Right * mapScale, 0, mapWidth);
+ double bottom = Math.Clamp(viewportInCanvas.Bottom * mapScale, 0, mapHeight);
+
+ Rect viewportOnMap = new(left, top, Math.Max(0, right - left), Math.Max(0, bottom - top));
+
+ ViewportRectangle.Margin = new Thickness(viewportOnMap.X, viewportOnMap.Y, 0, 0);
+ ViewportRectangle.Width = viewportOnMap.Width;
+ ViewportRectangle.Height = viewportOnMap.Height;
+
+ OutsideDim.Data = new CombinedGeometry(
+ GeometryCombineMode.Exclude,
+ new RectangleGeometry(new Rect(0, 0, mapWidth, mapHeight)),
+ new RectangleGeometry(viewportOnMap));
+
+ return true;
+ }
+
+ private void RequestCenter(Point mapPoint)
+ {
+ if (mapScale <= 0)
+ return;
+
+ ViewportCenterRequested?.Invoke(this, new Point(mapPoint.X / mapScale, mapPoint.Y / mapScale));
+ }
+
+ private void MapHost_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ isDragging = true;
+ MapHost.CaptureMouse();
+ RequestCenter(e.GetPosition(MapHost));
+ e.Handled = true;
+ }
+
+ private void MapHost_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (!isDragging)
+ return;
+
+ if (e.LeftButton != MouseButtonState.Pressed)
+ {
+ EndDrag();
+ return;
+ }
+
+ RequestCenter(e.GetPosition(MapHost));
+ e.Handled = true;
+ }
+
+ private void MapHost_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (!isDragging)
+ return;
+
+ EndDrag();
+ e.Handled = true;
+ }
+
+ private void MapHost_LostMouseCapture(object sender, MouseEventArgs e)
+ {
+ isDragging = false;
+ }
+
+ private void EndDrag()
+ {
+ isDragging = false;
+
+ if (MapHost.IsMouseCaptured)
+ MapHost.ReleaseMouseCapture();
+ }
+}
diff --git a/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs b/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs
index 3365b93..5dcbe73 100644
--- a/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs
+++ b/MagickCrop/Controls/PixelPrecisionZoom.xaml.cs
@@ -61,42 +61,49 @@ private void UpdateZoomPreview()
if (sourceImage == null)
return;
- try
+ if (sourceImage is not BitmapSource bitmapSource)
+ return;
+
+ // Render a fixed-size source region so the loupe retains its scale at image edges.
+ int captureWidth = Math.Max(1, (int)Math.Ceiling(DefaultPreviewSize / ZoomFactor));
+ int captureHeight = Math.Max(1, (int)Math.Ceiling(DefaultPreviewSize / ZoomFactor));
+ int originX = (int)Math.Floor(currentPosition.X - (captureWidth / 2.0));
+ int originY = (int)Math.Floor(currentPosition.Y - (captureHeight / 2.0));
+
+ int sourceLeft = Math.Clamp(originX, 0, bitmapSource.PixelWidth);
+ int sourceTop = Math.Clamp(originY, 0, bitmapSource.PixelHeight);
+ int sourceRight = Math.Clamp(originX + captureWidth, 0, bitmapSource.PixelWidth);
+ int sourceBottom = Math.Clamp(originY + captureHeight, 0, bitmapSource.PixelHeight);
+ int sourceWidth = sourceRight - sourceLeft;
+ int sourceHeight = sourceBottom - sourceTop;
+
+ RenderTargetBitmap preview = new(
+ captureWidth,
+ captureHeight,
+ bitmapSource.DpiX,
+ bitmapSource.DpiY,
+ PixelFormats.Pbgra32);
+ DrawingVisual visual = new();
+ using (DrawingContext context = visual.RenderOpen())
{
- // Create a RenderTargetBitmap to capture the source image
- if (sourceImage is BitmapSource bitmapSource)
+ context.DrawRectangle(Brushes.Black, null, new Rect(0, 0, captureWidth, captureHeight));
+ if (sourceWidth > 0 && sourceHeight > 0)
{
- // Calculate the region to capture (centered on current position)
- double captureWidth = DefaultPreviewSize / ZoomFactor;
- double captureHeight = DefaultPreviewSize / ZoomFactor;
-
- // Create a cropped version of the source
Int32Rect sourceRect = new(
- (int)Math.Max(0, currentPosition.X - captureWidth / 2),
- (int)Math.Max(0, currentPosition.Y - captureHeight / 2),
- (int)Math.Min(captureWidth, bitmapSource.PixelWidth - (currentPosition.X - captureWidth / 2)),
- (int)Math.Min(captureHeight, bitmapSource.PixelHeight - (currentPosition.Y - captureHeight / 2))
- );
-
- // Ensure valid rectangle
- if (sourceRect.Width > 0 && sourceRect.Height > 0 &&
- sourceRect.X >= 0 && sourceRect.Y >= 0 &&
- sourceRect.X + sourceRect.Width <= bitmapSource.PixelWidth &&
- sourceRect.Y + sourceRect.Height <= bitmapSource.PixelHeight)
- {
- CroppedBitmap croppedBitmap = new(bitmapSource, sourceRect);
-
- // Apply scaling transform
- TransformedBitmap transformedBitmap = new(croppedBitmap, new ScaleTransform(ZoomFactor, ZoomFactor));
-
- ZoomImage.Source = transformedBitmap;
- }
+ sourceLeft,
+ sourceTop,
+ sourceWidth,
+ sourceHeight);
+ CroppedBitmap croppedBitmap = new(bitmapSource, sourceRect);
+ context.DrawImage(
+ croppedBitmap,
+ new Rect(sourceLeft - originX, sourceTop - originY, sourceWidth, sourceHeight));
}
}
- catch (Exception)
- {
- // Silently handle any rendering errors
- }
+
+ preview.Render(visual);
+ preview.Freeze();
+ ZoomImage.Source = new TransformedBitmap(preview, new ScaleTransform(ZoomFactor, ZoomFactor));
}
///
diff --git a/MagickCrop/Controls/PolygonMeasurementControl.xaml.cs b/MagickCrop/Controls/PolygonMeasurementControl.xaml.cs
index 2d59586..ea547dd 100644
--- a/MagickCrop/Controls/PolygonMeasurementControl.xaml.cs
+++ b/MagickCrop/Controls/PolygonMeasurementControl.xaml.cs
@@ -14,6 +14,8 @@ public partial class PolygonMeasurementControl : UserControl
private readonly List vertexPoints = [];
private bool isClosed = false;
private int pointDraggingIndex = -1;
+ private bool areDragGizmosVisible = true;
+ private bool areEndpointCapsVisible;
private double scaleFactor = 1.0;
public double ScaleFactor
@@ -49,6 +51,37 @@ public PolygonMeasurementControl()
InitializeComponent();
}
+ public bool IsDragGizmoVisible
+ {
+ get => areDragGizmosVisible;
+ set
+ {
+ areDragGizmosVisible = value;
+ Visibility visibility = value ? Visibility.Visible : Visibility.Collapsed;
+ foreach (Ellipse point in vertexPoints)
+ point.Visibility = visibility;
+ }
+ }
+
+ public bool IsEndpointCapVisible
+ {
+ set
+ {
+ areEndpointCapsVisible = value;
+ double size = value ? 6 : 12;
+ foreach (Ellipse point in vertexPoints)
+ {
+ point.Width = size;
+ point.Height = size;
+ }
+
+ if (!value && !isClosed && vertices.Count >= 3)
+ UpdateFirstVertexAppearance();
+ else
+ UpdateVertexPositions();
+ }
+ }
+
public void AddVertex(Point vertex)
{
if (isClosed) return;
@@ -135,8 +168,8 @@ private void CreateVertexPoint(Point position, int index)
{
Ellipse ellipse = new()
{
- Width = 12,
- Height = 12,
+ Width = areEndpointCapsVisible ? 6 : 12,
+ Height = areEndpointCapsVisible ? 6 : 12,
Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0066FF")),
Stroke = Brushes.White,
StrokeThickness = 1,
@@ -154,6 +187,7 @@ private void CreateVertexPoint(Point position, int index)
vertexPoints.Add(ellipse);
MeasurementCanvas.Children.Add(ellipse);
+ ellipse.Visibility = areDragGizmosVisible ? Visibility.Visible : Visibility.Collapsed;
}
private void UpdatePolygonPath()
diff --git a/MagickCrop/Controls/RectangleMeasurementControl.xaml.cs b/MagickCrop/Controls/RectangleMeasurementControl.xaml.cs
index 23be530..3eb38d4 100644
--- a/MagickCrop/Controls/RectangleMeasurementControl.xaml.cs
+++ b/MagickCrop/Controls/RectangleMeasurementControl.xaml.cs
@@ -45,6 +45,30 @@ public RectangleMeasurementControl()
UpdatePositions();
}
+ public bool IsDragGizmoVisible
+ {
+ get => TopLeftPoint.Visibility == Visibility.Visible;
+ set
+ {
+ Visibility visibility = value ? Visibility.Visible : Visibility.Collapsed;
+ TopLeftPoint.Visibility = visibility;
+ BottomRightPoint.Visibility = visibility;
+ }
+ }
+
+ public bool IsEndpointCapVisible
+ {
+ set
+ {
+ double size = value ? 6 : 12;
+ TopLeftPoint.Width = size;
+ TopLeftPoint.Height = size;
+ BottomRightPoint.Width = size;
+ BottomRightPoint.Height = size;
+ UpdatePositions();
+ }
+ }
+
public void InitializePositions(double canvasWidth, double canvasHeight)
{
topLeft = new Point(canvasWidth * 0.3, canvasHeight * 0.3);
@@ -88,7 +112,7 @@ private void UpdateMeasurementText()
double scaledHeight = height * ScaleFactor;
double scaledArea = area * ScaleFactor * ScaleFactor; // Area scales by factor squared
- RectangleTextBlock.Text = $"{scaledWidth:N2} \u00D7 {scaledHeight:N2} {Units} (A: {scaledArea:N2} {Units}²)";
+ RectangleTextBlock.Text = $"{scaledWidth:N2} \u00D7 {scaledHeight:N2} {Units} (A: {scaledArea:N2} {Units}\u00B2)";
}
private void MeasurementPoint_MouseDown(object sender, MouseButtonEventArgs e)
diff --git a/MagickCrop/Controls/SaveOptionsDialog.xaml b/MagickCrop/Controls/SaveOptionsDialog.xaml
index 9b39611..5d52f56 100644
--- a/MagickCrop/Controls/SaveOptionsDialog.xaml
+++ b/MagickCrop/Controls/SaveOptionsDialog.xaml
@@ -18,6 +18,7 @@
+
@@ -132,16 +133,34 @@
-
+
+
+
+
+
+
+
diff --git a/MagickCrop/Controls/SaveOptionsDialog.xaml.cs b/MagickCrop/Controls/SaveOptionsDialog.xaml.cs
index 6bfa06d..774448c 100644
--- a/MagickCrop/Controls/SaveOptionsDialog.xaml.cs
+++ b/MagickCrop/Controls/SaveOptionsDialog.xaml.cs
@@ -1,7 +1,9 @@
using ImageMagick;
using MagickCrop.Models;
+using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Threading;
namespace MagickCrop.Controls;
@@ -21,12 +23,28 @@ public partial class SaveOptionsDialog : UserControl
private double originalHeight;
private double aspectRatio;
private bool updatingDimensions = false;
+ private readonly Func> estimateFileSizeAsync;
+ private readonly DispatcherTimer estimateDebounceTimer;
+ private readonly SemaphoreSlim estimateGate = new(1, 1);
+ private CancellationTokenSource? estimateCancellation;
+ private int estimateRequestId;
public SaveOptions Options { get; private set; }
- public SaveOptionsDialog(double imageWidth, double imageHeight)
+ public SaveOptionsDialog(
+ double imageWidth,
+ double imageHeight,
+ Func> estimateFileSizeAsync)
{
InitializeComponent();
+ this.estimateFileSizeAsync = estimateFileSizeAsync;
+ estimateDebounceTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(350)
+ };
+ estimateDebounceTimer.Tick += EstimateDebounceTimer_Tick;
+ Loaded += SaveOptionsDialog_Loaded;
+ Unloaded += SaveOptionsDialog_Unloaded;
// Store original dimensions and calculate aspect ratio
originalWidth = imageWidth;
@@ -46,13 +64,24 @@ public SaveOptionsDialog(double imageWidth, double imageHeight)
{
Format = MagickFormat.Png,
Extension = ".png",
+ Quality = (int)QualitySlider.Value,
Resize = false,
Width = (int)originalWidth,
Height = (int)originalHeight,
- MaintainAspectRatio = true
+ MaintainAspectRatio = true,
+ IncludeMarkup = false,
+ IncludeMeasurements = false
};
}
+ private void SaveOptionsDialog_Loaded(object sender, RoutedEventArgs e) => ScheduleSizeEstimate();
+
+ private void SaveOptionsDialog_Unloaded(object sender, RoutedEventArgs e)
+ {
+ estimateDebounceTimer.Stop();
+ estimateCancellation?.Cancel();
+ }
+
private void FormatComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!IsLoaded) return;
@@ -64,6 +93,7 @@ private void FormatComboBox_SelectionChanged(object sender, SelectionChangedEven
// Show/hide quality slider based on format
QualityGrid.Visibility = selectedFormat.SupportsQuality ? Visibility.Visible : Visibility.Collapsed;
+ ScheduleSizeEstimate();
}
}
@@ -74,6 +104,7 @@ private void QualitySlider_ValueChanged(object sender, RoutedPropertyChangedEven
int quality = (int)QualitySlider.Value;
QualityValueText.Text = $"{quality}%";
Options.Quality = quality;
+ ScheduleSizeEstimate();
}
private void ResizeCheckBox_CheckedChanged(object sender, RoutedEventArgs e)
@@ -87,6 +118,7 @@ private void ResizeCheckBox_CheckedChanged(object sender, RoutedEventArgs e)
MaintainAspectRatioCheckBox.IsEnabled = isChecked;
Options.Resize = isChecked;
+ ScheduleSizeEstimate();
}
private void WidthBox_ValueChanged(object sender, RoutedEventArgs e)
@@ -105,6 +137,8 @@ private void WidthBox_ValueChanged(object sender, RoutedEventArgs e)
Options.Height = (int)HeightBox.Value.Value;
updatingDimensions = false;
}
+
+ ScheduleSizeEstimate();
}
private void HeightBox_ValueChanged(object sender, RoutedEventArgs e)
@@ -123,12 +157,98 @@ private void HeightBox_ValueChanged(object sender, RoutedEventArgs e)
Options.Width = (int)WidthBox.Value.Value;
updatingDimensions = false;
}
+
+ ScheduleSizeEstimate();
+ }
+
+ private void IncludeMarkupCheckBox_CheckedChanged(object sender, RoutedEventArgs e)
+ {
+ if (!IsLoaded) return;
+
+ Options.IncludeMarkup = IncludeMarkupCheckBox.IsChecked == true;
+ ScheduleSizeEstimate();
+ }
+
+ private void IncludeMeasurementsCheckBox_CheckedChanged(object sender, RoutedEventArgs e)
+ {
+ if (!IsLoaded) return;
+
+ Options.IncludeMeasurements = IncludeMeasurementsCheckBox.IsChecked == true;
+ ScheduleSizeEstimate();
+ }
+
+ private void ScheduleSizeEstimate()
+ {
+ if (!IsLoaded)
+ return;
+
+ estimateCancellation?.Cancel();
+ estimateDebounceTimer.Stop();
+ estimateDebounceTimer.Start();
+ EstimatedSizeText.Text = "Estimated file size: Calculating...";
+ }
+
+ private async void EstimateDebounceTimer_Tick(object? sender, EventArgs e)
+ {
+ estimateDebounceTimer.Stop();
+ estimateCancellation?.Cancel();
+
+ CancellationTokenSource cancellation = new();
+ estimateCancellation = cancellation;
+ int requestId = ++estimateRequestId;
+ bool enteredEstimateGate = false;
+
+ try
+ {
+ await estimateGate.WaitAsync(cancellation.Token);
+ enteredEstimateGate = true;
+ long size = await estimateFileSizeAsync(Options.Clone(), cancellation.Token);
+ if (!cancellation.IsCancellationRequested && requestId == estimateRequestId && IsLoaded)
+ EstimatedSizeText.Text = $"Estimated file size: {FormatFileSize(size)}";
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Unable to estimate saved image size: {ex}");
+ if (requestId == estimateRequestId && IsLoaded)
+ EstimatedSizeText.Text = "Estimated file size: Unavailable";
+ }
+ finally
+ {
+ if (enteredEstimateGate)
+ estimateGate.Release();
+
+ if (ReferenceEquals(estimateCancellation, cancellation))
+ estimateCancellation = null;
+
+ cancellation.Dispose();
+ }
+ }
+
+ private static string FormatFileSize(long bytes)
+ {
+ string[] units = ["B", "KB", "MB", "GB"];
+ double size = bytes;
+ int unitIndex = 0;
+
+ while (size >= 1024 && unitIndex < units.Length - 1)
+ {
+ size /= 1024;
+ unitIndex++;
+ }
+
+ return unitIndex == 0 ? $"{size:N0} {units[unitIndex]}" : $"{size:N1} {units[unitIndex]}";
}
private async void SaveButton_Click(object sender, RoutedEventArgs e)
{
// Update final options
Options.MaintainAspectRatio = MaintainAspectRatioCheckBox.IsChecked == true;
+ Options.IncludeMarkup = IncludeMarkupCheckBox.IsChecked == true;
+ Options.IncludeMeasurements = IncludeMeasurementsCheckBox.IsChecked == true;
+ estimateCancellation?.Cancel();
if (WidthBox.Value is 0 || HeightBox.Value is 0)
{
@@ -148,6 +268,7 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e)
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
+ estimateCancellation?.Cancel();
Window.GetWindow(this).DialogResult = false;
Window.GetWindow(this).Close();
}
diff --git a/MagickCrop/Helpers/LensMetadataHelper.cs b/MagickCrop/Helpers/LensMetadataHelper.cs
new file mode 100644
index 0000000..dff2bd1
--- /dev/null
+++ b/MagickCrop/Helpers/LensMetadataHelper.cs
@@ -0,0 +1,51 @@
+using ImageMagick;
+using System;
+
+namespace MagickCrop.Helpers;
+
+public record LensMetadata(
+ string? CameraMake,
+ string? CameraModel,
+ string? LensMake,
+ string? LensModel,
+ double? FocalLength,
+ double? FNumber,
+ int? Orientation
+);
+
+public static class LensMetadataHelper
+{
+ public static LensMetadata? Read(string imagePath)
+ {
+ try
+ {
+ using MagickImage img = new(imagePath);
+ var exif = img.GetExifProfile();
+ if (exif is null)
+ return null;
+
+ string? make = exif.GetValue(ExifTag.Make)?.ToString();
+ string? model = exif.GetValue(ExifTag.Model)?.ToString();
+ string? lensModel = exif.GetValue(ExifTag.LensModel)?.ToString();
+ string? lensMake = exif.GetValue(ExifTag.LensMake)?.ToString();
+
+ double? focal = null;
+ var fl = exif.GetValue(ExifTag.FocalLength)?.ToString();
+ if (fl is not null && double.TryParse(fl, out double fld)) focal = fld;
+
+ double? fnum = null;
+ var fn = exif.GetValue(ExifTag.FNumber)?.ToString();
+ if (fn is not null && double.TryParse(fn, out double fnd)) fnum = fnd;
+
+ int? orientation = null;
+ var orient = exif.GetValue(ExifTag.Orientation)?.ToString();
+ if (orient is not null && int.TryParse(orient, out int o)) orientation = o;
+
+ return new LensMetadata(make, model, lensMake, lensModel, focal, fnum, orientation);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+}
diff --git a/MagickCrop/MagickCrop.csproj b/MagickCrop/MagickCrop.csproj
index 111fdd9..f644039 100644
--- a/MagickCrop/MagickCrop.csproj
+++ b/MagickCrop/MagickCrop.csproj
@@ -37,17 +37,20 @@
PreserveNewest
+
+ PreserveNewest
+
-
-
-
-
-
+
+
+
+
+
diff --git a/MagickCrop/MainWindow.CornerNavigation.cs b/MagickCrop/MainWindow.CornerNavigation.cs
new file mode 100644
index 0000000..7e42a3e
--- /dev/null
+++ b/MagickCrop/MainWindow.CornerNavigation.cs
@@ -0,0 +1,226 @@
+ο»Ώusing MagickCrop.Controls;
+using MagickCrop.Helpers;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using System.Windows.Shapes;
+
+namespace MagickCrop;
+
+public partial class MainWindow
+{
+ /// Distance, in screen pixels, from a handle centre to its navigation buttons.
+ private const double CornerNavOffset = 55;
+
+ ///
+ /// Neighbouring handles closer together than this (in screen pixels) don't get navigation
+ /// buttons, because the jump would be pointless and the buttons would cover the handles.
+ ///
+ private const double CornerNavMinSeparation = 140;
+
+ private readonly List cornerNavButtons = [];
+ private bool isCanvasTranslateAnimating;
+ private int canvasTranslateAnimationToken;
+
+ ///
+ /// Returns the handles of the active transform, ordered as a closed ring, so each handle
+ /// knows its previous and next neighbour.
+ ///
+ private List GetActiveHandleRing()
+ {
+ if (isUnWarpMode)
+ {
+ return [TopLeft, UnWarpMidTop, TopRight, UnWarpMidRight,
+ BottomRight, UnWarpMidBottom, BottomLeft, UnWarpMidLeft];
+ }
+
+ if (isTriFoldMode)
+ {
+ return [TopLeft, TopRight, UpperFoldRight, LowerFoldRight,
+ BottomRight, BottomLeft, LowerFoldLeft, UpperFoldLeft];
+ }
+
+ Ellipse[] corners = [TopLeft, TopRight, BottomRight, BottomLeft];
+ if (corners.All(corner => corner.Visibility == Visibility.Visible))
+ return [.. corners];
+
+ return [];
+ }
+
+ ///
+ /// Rebuilds the per-handle "jump to neighbour" buttons for the active transform mode.
+ /// Call whenever the set of visible handles changes.
+ ///
+ private void RefreshCornerNavButtons()
+ {
+ foreach (CornerNavButton button in cornerNavButtons)
+ {
+ button.Click -= CornerNavButton_Click;
+ ShapeCanvas.Children.Remove(button);
+ }
+
+ cornerNavButtons.Clear();
+
+ List ring = GetActiveHandleRing();
+ if (ring.Count < 3)
+ return;
+
+ for (int i = 0; i < ring.Count; i++)
+ {
+ Ellipse anchor = ring[i];
+ Ellipse next = ring[(i + 1) % ring.Count];
+ Ellipse previous = ring[((i - 1) + ring.Count) % ring.Count];
+
+ AddCornerNavButton(anchor, next, "Center the view on the next point");
+ AddCornerNavButton(anchor, previous, "Center the view on the previous point");
+ }
+
+ UpdateCornerNavButtons();
+ }
+
+ private void AddCornerNavButton(Ellipse anchor, Ellipse target, string toolTip)
+ {
+ CornerNavButton button = new()
+ {
+ Anchor = anchor,
+ Target = target,
+ ToolTip = toolTip,
+ Visibility = Visibility.Collapsed,
+ };
+
+ button.Click += CornerNavButton_Click;
+ cornerNavButtons.Add(button);
+ ShapeCanvas.Children.Add(button);
+ }
+
+ ///
+ /// Positions each navigation button a fixed screen distance from its handle, along the
+ /// direction of its target, and counter-scales it so it stays the same size at any zoom.
+ ///
+ private void UpdateCornerNavButtons()
+ {
+ if (cornerNavButtons.Count == 0)
+ return;
+
+ double scale = Math.Max(MinZoom, canvasScale.ScaleX);
+ double inverseScale = 1.0 / scale;
+
+ foreach (CornerNavButton button in cornerNavButtons)
+ {
+ if (button.Anchor is not Ellipse anchor || button.Target is not Ellipse target
+ || anchor.Visibility != Visibility.Visible || target.Visibility != Visibility.Visible)
+ {
+ button.Visibility = Visibility.Collapsed;
+ continue;
+ }
+
+ Point anchorCenter = GeometryMathHelper.GetEllipseCenter(anchor);
+ Point targetCenter = GeometryMathHelper.GetEllipseCenter(target);
+
+ Vector direction = targetCenter - anchorCenter;
+ double screenDistance = direction.Length * scale;
+
+ // Hide the shortcut when the neighbour is already close enough to reach by eye.
+ if (screenDistance < CornerNavMinSeparation)
+ {
+ button.Visibility = Visibility.Collapsed;
+ continue;
+ }
+
+ direction.Normalize();
+ Point position = anchorCenter + (direction * (CornerNavOffset * inverseScale));
+
+ Canvas.SetLeft(button, position.X - (button.Width / 2));
+ Canvas.SetTop(button, position.Y - (button.Height / 2));
+
+ button.RenderTransformOrigin = new Point(0.5, 0.5);
+ button.RenderTransform = new ScaleTransform(inverseScale, inverseScale);
+ button.ArrowAngle = Math.Atan2(direction.Y, direction.X) * 180.0 / Math.PI;
+ button.Visibility = Visibility.Visible;
+ }
+ }
+
+ private void CornerNavButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not CornerNavButton { Target: Ellipse target })
+ return;
+
+ CenterViewportOnCanvasPoint(GeometryMathHelper.GetEllipseCenter(target), animate: true);
+ e.Handled = true;
+ }
+
+ ///
+ /// Pans the canvas so the given canvas-space point sits in the middle of the viewport.
+ /// The zoom level is left untouched.
+ ///
+ private void CenterViewportOnCanvasPoint(Point canvasPoint, bool animate)
+ {
+ double scale = canvasScale.ScaleX;
+ if (scale <= 0 || MainGrid.ActualWidth <= 0 || MainGrid.ActualHeight <= 0)
+ return;
+
+ double targetX = (MainGrid.ActualWidth / 2) - CanvasOriginOffset - (canvasPoint.X * scale);
+ double targetY = (MainGrid.ActualHeight / 2) - CanvasOriginOffset - (canvasPoint.Y * scale);
+
+ StopCanvasTranslateAnimation();
+
+ if (!animate)
+ {
+ canvasTranslate.X = targetX;
+ canvasTranslate.Y = targetY;
+ return;
+ }
+
+ DoubleAnimation xAnimation = CreateTranslateAnimation(targetX);
+ DoubleAnimation yAnimation = CreateTranslateAnimation(targetY);
+
+ // Removing an animation does not cancel its clock, so a superseded animation still
+ // raises Completed. The token makes those late callbacks no-ops.
+ int token = ++canvasTranslateAnimationToken;
+
+ xAnimation.Completed += (_, _) =>
+ {
+ if (token != canvasTranslateAnimationToken)
+ return;
+
+ isCanvasTranslateAnimating = false;
+ canvasTranslate.BeginAnimation(TranslateTransform.XProperty, null);
+ canvasTranslate.BeginAnimation(TranslateTransform.YProperty, null);
+ canvasTranslate.X = targetX;
+ canvasTranslate.Y = targetY;
+ };
+
+ isCanvasTranslateAnimating = true;
+ canvasTranslate.BeginAnimation(TranslateTransform.XProperty, xAnimation);
+ canvasTranslate.BeginAnimation(TranslateTransform.YProperty, yAnimation);
+ }
+
+ private static DoubleAnimation CreateTranslateAnimation(double to) => new(to, TimeSpan.FromMilliseconds(220))
+ {
+ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut },
+ FillBehavior = FillBehavior.Stop,
+ };
+
+ ///
+ /// Cancels an in-flight jump animation, keeping whatever position it had reached, so manual
+ /// panning and zooming stay responsive.
+ ///
+ private void StopCanvasTranslateAnimation()
+ {
+ if (!isCanvasTranslateAnimating)
+ return;
+
+ isCanvasTranslateAnimating = false;
+ canvasTranslateAnimationToken++;
+
+ double currentX = canvasTranslate.X;
+ double currentY = canvasTranslate.Y;
+
+ canvasTranslate.BeginAnimation(TranslateTransform.XProperty, null);
+ canvasTranslate.BeginAnimation(TranslateTransform.YProperty, null);
+
+ canvasTranslate.X = currentX;
+ canvasTranslate.Y = currentY;
+ }
+}
diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml
index 2dbb5c3..4a0532e 100644
--- a/MagickCrop/MainWindow.xaml
+++ b/MagickCrop/MainWindow.xaml
@@ -37,6 +37,39 @@
+
+
+
+
@@ -55,13 +118,14 @@
-
+
+
@@ -87,7 +151,23 @@
Background="Gray"
ClipToBounds="True"
IsManipulationEnabled="True"
+ PreviewMouseDown="MainGrid_PreviewMouseDown"
+ PreviewMouseUp="MainGrid_PreviewMouseUp"
PreviewMouseWheel="ShapeCanvas_PreviewMouseWheel">
+
+
+
+
+
+
@@ -278,6 +369,148 @@
Text="0Β°"
Visibility="Collapsed" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -337,7 +596,7 @@
-
+
@@ -815,6 +1074,15 @@
+
+ Grid.ColumnSpan="3" />
diff --git a/MagickCrop/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs
index be8a5bb..2b1bcdf 100644
--- a/MagickCrop/MainWindow.xaml.cs
+++ b/MagickCrop/MainWindow.xaml.cs
@@ -8,6 +8,7 @@
using Microsoft.Win32;
using Microsoft.Windows.Media.Capture;
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
@@ -45,6 +46,13 @@ public partial class MainWindow : FluentWindow, IMainWindowView
bool IMainWindowView.IsLocalAdjustment => LocalAdjustmentCheckBox.IsChecked == true;
+ bool IMainWindowView.HasMeasurements =>
+ measurementTools.Count > 0
+ || angleMeasurementTools.Count > 0
+ || rectangleMeasurementTools.Count > 0
+ || polygonMeasurementTools.Count > 0
+ || circleMeasurementTools.Count > 0;
+
MagickGeometry IMainWindowView.GetLocalAdjustmentRegion() => LocalAdjustmentRectangle.CropShape;
void IMainWindowView.SetBusy(bool busy)
@@ -58,8 +66,14 @@ void IMainWindowView.SetBusy(bool busy)
Window IMainWindowView.OwnerWindow => this;
private Point clickedPoint = new();
+ private Vector handleGrabOffset = new();
private Size oldGridSize = new();
private FrameworkElement? clickedElement;
+ private bool allowHandlesOutsideImage = true;
+ private bool isUpdatingCanvasNavigation;
+ private bool showMiniMap = true;
+ private const double CanvasOriginOffset = 50;
+ private const double DefaultSidebarWidth = 240;
// Size input properties
private bool isUpdatingFromCode = false;
@@ -116,6 +130,18 @@ void IMainWindowView.SetBusy(bool busy)
private Point markupShapeBeforePoint2;
private int markupShapeBeforeDragIndex = -1;
+ // --- Markup group selection (Select tool: ink + shapes + text together) ---
+ private readonly HashSet selectedMarkupShapes = [];
+ private readonly HashSet selectedMarkupTexts = [];
+ private readonly List markupSelectionHighlights = [];
+ private Point? markupMarqueeStartPoint;
+ private Point markupGroupDragLastPoint;
+ private double markupGroupDragTotalDeltaX;
+ private double markupGroupDragTotalDeltaY;
+ private StrokeCollection? markupGroupMoveStrokes;
+ private List? markupGroupMoveShapes;
+ private List? markupGroupMoveTexts;
+
private Services.RecentProjectsManager? recentProjectsManager;
private System.Timers.Timer? autoSaveTimer;
private readonly int AutoSaveIntervalMs = (int)TimeSpan.FromSeconds(5).TotalMilliseconds;
@@ -215,6 +241,14 @@ public MainWindow()
ApplicationAccentColorManager.Apply(teal);
InitializeComponent();
+ canvasScale.Changed += CanvasScale_Changed;
+ canvasTranslate.Changed += CanvasTranslate_Changed;
+ CanvasMiniMap.ViewportCenterRequested += CanvasMiniMap_ViewportCenterRequested;
+ MainGrid.SizeChanged += (_, _) => UpdateMiniMap();
+ MainImage.SizeChanged += (_, _) => UpdateMiniMap();
+ DependencyPropertyDescriptor
+ .FromProperty(System.Windows.Controls.Image.SourceProperty, typeof(System.Windows.Controls.Image))
+ ?.AddValueChanged(MainImage, (_, _) => UpdateMiniMap());
// Ensure zoom still works if mouse wheel fires at window level (after a pan or when mouse over other element)
PreviewMouseWheel += ShapeCanvas_PreviewMouseWheel;
@@ -267,7 +301,10 @@ public MainWindow()
ShapeCanvas.MouseUp += ShapeCanvas_MouseUp;
ShapeCanvas.LostMouseCapture += ShapeCanvas_LostMouseCapture; // safety to ensure capture released
+ MainGrid.LostMouseCapture += MainGrid_LostMouseCapture;
rotationOverlayLabel = FindName("RotationOverlayLabel") as WpfTextBlock; // cache
+ UpdateCanvasNavigationUi();
+ UpdateTransformVisualScale();
CheckObjectEraseAvailability();
}
@@ -292,7 +329,19 @@ private async void CheckObjectEraseAvailability()
private void ShapeCanvas_LostMouseCapture(object sender, MouseEventArgs e)
{
if (draggingMode == DraggingMode.Panning)
+ {
+ draggingMode = DraggingMode.None;
+ Cursor = null;
+ }
+ }
+
+ private void MainGrid_LostMouseCapture(object sender, MouseEventArgs e)
+ {
+ if (draggingMode == DraggingMode.Panning)
+ {
draggingMode = DraggingMode.None;
+ Cursor = null;
+ }
}
private void DrawPolyLine()
@@ -326,6 +375,8 @@ private void DrawPolyLine()
// Keep _polygonElements in sync with the new lines reference
if (_polygonElements is not null && _polygonElements.Count > 0)
_polygonElements[0] = lines;
+
+ UpdateTransformVisualScale();
}
private void TopLeft_MouseDown(object sender, MouseButtonEventArgs e)
@@ -342,6 +393,10 @@ private void TopLeft_MouseDown(object sender, MouseButtonEventArgs e)
clickedElement = ellipse;
draggingMode = DraggingMode.MoveElement;
clickedPoint = e.GetPosition(ShapeCanvas);
+ Point handleCenter = new(
+ Canvas.GetLeft(ellipse) + (ellipse.Width / 2),
+ Canvas.GetTop(ellipse) + (ellipse.Height / 2));
+ handleGrabOffset = clickedPoint - handleCenter;
CaptureMouse();
// Show pixel zoom for precise corner placement
@@ -505,9 +560,9 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e)
else if (markupShapeBeforeDragIndex >= 0)
{
// Existing handle dragged β record undo for the point move
- var (afterP1, afterP2) = activeMarkupShapeControl.GetPoints();
+ (Point afterP1, Point afterP2) = activeMarkupShapeControl.GetPoints();
Point before = markupShapeBeforeDragIndex == 0 ? markupShapeBeforePoint1 : markupShapeBeforePoint2;
- Point after = markupShapeBeforeDragIndex == 0 ? afterP1 : afterP2;
+ Point after = markupShapeBeforeDragIndex == 0 ? afterP1 : afterP2;
if (before != after)
{
MarkupShapeControl ctrl = activeMarkupShapeControl;
@@ -520,7 +575,18 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e)
isMarkupShapeDragCreation = false;
}
+ if (draggingMode == DraggingMode.MarkupGroupSelect)
+ {
+ FinishMarkupMarquee(e.GetPosition(ShapeCanvas));
+ }
+
+ if (draggingMode == DraggingMode.MarkupGroupMove)
+ {
+ FinishMarkupGroupMove();
+ }
+
clickedElement = null;
+ pointDraggingIndex = -1;
ReleaseMouseCapture();
draggingMode = DraggingMode.None;
@@ -540,6 +606,29 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e)
}
Point movingPoint = e.GetPosition(ShapeCanvas);
+
+ if (draggingMode == DraggingMode.MarkupGroupSelect)
+ {
+ UpdateMarkupMarqueeVisual(movingPoint);
+ e.Handled = true;
+ return;
+ }
+
+ if (draggingMode == DraggingMode.MarkupGroupMove)
+ {
+ double groupDeltaX = movingPoint.X - markupGroupDragLastPoint.X;
+ double groupDeltaY = movingPoint.Y - markupGroupDragLastPoint.Y;
+ if (groupDeltaX != 0 || groupDeltaY != 0)
+ {
+ ApplyMarkupGroupDelta(groupDeltaX, groupDeltaY);
+ markupGroupDragTotalDeltaX += groupDeltaX;
+ markupGroupDragTotalDeltaY += groupDeltaY;
+ markupGroupDragLastPoint = movingPoint;
+ }
+ e.Handled = true;
+ return;
+ }
+
if (draggingMode == DraggingMode.MeasureDistance && activeMeasureControl is not null)
{
int pointIndex = activeMeasureControl.GetActivePointIndex();
@@ -623,10 +712,15 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e)
if (draggingMode != DraggingMode.MoveElement || clickedElement is null)
return;
- Canvas.SetTop(clickedElement, movingPoint.Y - (clickedElement.Height / 2));
- Canvas.SetLeft(clickedElement, movingPoint.X - (clickedElement.Width / 2));
+ Point newHandleCenter = new(
+ movingPoint.X - handleGrabOffset.X,
+ movingPoint.Y - handleGrabOffset.Y);
+ newHandleCenter = ConstrainHandlePosition(newHandleCenter);
+ Canvas.SetTop(clickedElement, newHandleCenter.Y - (clickedElement.Height / 2));
+ Canvas.SetLeft(clickedElement, newHandleCenter.X - (clickedElement.Width / 2));
- MovePolyline(movingPoint);
+ MovePolyline(newHandleCenter);
+ UpdateCornerNavButtons();
if (draggingMode == DraggingMode.CreatingMeasurement && isCreatingMeasurement)
{
@@ -709,6 +803,8 @@ private void UpdateResizeTextBoxesFromDrag()
private void PanCanvas(MouseEventArgs e)
{
+ StopCanvasTranslateAnimation();
+
Point currentPosition = e.GetPosition(this);
Vector delta = currentPosition - clickedPoint;
@@ -719,6 +815,48 @@ private void PanCanvas(MouseEventArgs e)
clickedPoint = currentPosition;
}
+ private Point ConstrainHandlePosition(Point position)
+ {
+ if (allowHandlesOutsideImage || MainImage.ActualWidth <= 0 || MainImage.ActualHeight <= 0)
+ return position;
+
+ return new Point(
+ Math.Clamp(position.X, 0, MainImage.ActualWidth),
+ Math.Clamp(position.Y, 0, MainImage.ActualHeight));
+ }
+
+ private IEnumerable GetTransformHandles()
+ {
+ yield return TopLeft;
+ yield return TopRight;
+ yield return BottomRight;
+ yield return BottomLeft;
+ yield return UpperFoldLeft;
+ yield return UpperFoldRight;
+ yield return LowerFoldLeft;
+ yield return LowerFoldRight;
+ yield return UnWarpMidTop;
+ yield return UnWarpMidRight;
+ yield return UnWarpMidBottom;
+ yield return UnWarpMidLeft;
+ }
+
+ private void UpdateTransformVisualScale()
+ {
+ double scale = Math.Max(MinZoom, canvasScale.ScaleX);
+ double inverseScale = 1.0 / scale;
+
+ foreach (Ellipse handle in GetTransformHandles())
+ {
+ handle.RenderTransformOrigin = new Point(0.5, 0.5);
+ handle.RenderTransform = new ScaleTransform(inverseScale, inverseScale);
+ }
+
+ lines?.StrokeThickness = 2 * inverseScale;
+
+ UpdateCornerNavButtons();
+ }
+
private void MovePolyline(Point newPoint)
{
if (pointDraggingIndex < 0)
@@ -1027,7 +1165,14 @@ private async void Save_Click(object sender, RoutedEventArgs e)
magickImage.Dispose();
// Create and show save options dialog in a window
- SaveOptionsDialog saveOptionsDialog = new(width, height);
+ SaveOptionsDialog saveOptionsDialog = new(
+ width,
+ height,
+ (options, cancellationToken) => EstimateSavedImageSizeAsync(
+ options,
+ (int)width,
+ (int)height,
+ cancellationToken));
Window dialogWindow = new()
{
Title = "Save Options",
@@ -1069,21 +1214,8 @@ private async void Save_Click(object sender, RoutedEventArgs e)
string correctedImageFileName = saveFileDialog.FileName;
- // Load image and apply options
- using MagickImage image = new(ViewModel.ImagePath);
-
- // Resize if requested
- if (options.Resize)
- {
- MagickGeometry resizeGeometry = new((uint)options.Width, (uint)options.Height)
- {
- IgnoreAspectRatio = !options.MaintainAspectRatio
- };
- image.Resize(resizeGeometry);
- }
-
- // Set quality for formats that support it
- image.Quality = (uint)options.Quality;
+ using MagickImage image = CreateImageForSave(options, (int)width, (int)height);
+ ApplySaveOptions(image, options);
// Save with the selected format
await image.WriteAsync(correctedImageFileName, options.Format);
@@ -1110,6 +1242,195 @@ private async void Save_Click(object sender, RoutedEventArgs e)
}
}
+ private MagickImage CreateImageForSave(SaveOptions options, int imageWidth, int imageHeight)
+ {
+ if (!options.IncludeMarkup && !options.IncludeMeasurements)
+ return new MagickImage(ViewModel.ImagePath);
+
+ BitmapSource renderedImage = RenderImageWithSelectedOverlays(
+ imageWidth,
+ imageHeight,
+ options.IncludeMarkup,
+ options.IncludeMeasurements);
+
+ return new MagickImage(EncodeBitmapAsPng(renderedImage));
+ }
+
+ private async Task EstimateSavedImageSizeAsync(
+ SaveOptions options,
+ int imageWidth,
+ int imageHeight,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!options.IncludeMarkup && !options.IncludeMeasurements)
+ {
+ string imagePath = ViewModel.ImagePath;
+ return await Task.Run(
+ () => EncodeImageForSave(new MagickImage(imagePath), options, cancellationToken),
+ cancellationToken);
+ }
+
+ BitmapSource renderedImage = RenderImageWithSelectedOverlays(
+ imageWidth,
+ imageHeight,
+ options.IncludeMarkup,
+ options.IncludeMeasurements);
+ byte[] renderedImageBytes = EncodeBitmapAsPng(renderedImage);
+
+ return await Task.Run(
+ () => EncodeImageForSave(new MagickImage(renderedImageBytes), options, cancellationToken),
+ cancellationToken);
+ }
+
+ private static long EncodeImageForSave(
+ MagickImage image,
+ SaveOptions options,
+ CancellationToken cancellationToken)
+ {
+ using (image)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ApplySaveOptions(image, options);
+ cancellationToken.ThrowIfCancellationRequested();
+ return image.ToByteArray(options.Format).LongLength;
+ }
+ }
+
+ private static byte[] EncodeBitmapAsPng(BitmapSource image)
+ {
+ PngBitmapEncoder encoder = new();
+ encoder.Frames.Add(BitmapFrame.Create(image));
+ using MemoryStream stream = new();
+ encoder.Save(stream);
+ return stream.ToArray();
+ }
+
+ private static void ApplySaveOptions(MagickImage image, SaveOptions options)
+ {
+ if (options.Resize)
+ {
+ MagickGeometry resizeGeometry = new((uint)options.Width, (uint)options.Height)
+ {
+ IgnoreAspectRatio = !options.MaintainAspectRatio
+ };
+ image.Resize(resizeGeometry);
+ }
+
+ if (options.Format is MagickFormat.Jpg or MagickFormat.WebP)
+ image.Quality = (uint)options.Quality;
+ }
+
+ private BitmapSource RenderImageWithSelectedOverlays(
+ int imageWidth,
+ int imageHeight,
+ bool includeMarkup,
+ bool includeMeasurements)
+ {
+ if (MainImage.ActualWidth <= 0 || MainImage.ActualHeight <= 0)
+ throw new InvalidOperationException("The image must be loaded before annotations can be saved.");
+
+ HashSet includedElements = [ImageGrid];
+
+ if (includeMeasurements)
+ {
+ includedElements.UnionWith(measurementTools);
+ includedElements.UnionWith(angleMeasurementTools);
+ includedElements.UnionWith(rectangleMeasurementTools);
+ includedElements.UnionWith(polygonMeasurementTools);
+ includedElements.UnionWith(circleMeasurementTools);
+ includedElements.UnionWith(verticalLineControls);
+ includedElements.UnionWith(horizontalLineControls);
+ includedElements.UnionWith(ShapeCanvas.Children.OfType());
+ }
+
+ if (includeMarkup)
+ {
+ includedElements.UnionWith(markupShapeControls);
+ includedElements.UnionWith(markupTextControls);
+ }
+
+ Dictionary visibilityBeforeRender = [];
+ void SetVisibilityForRender(UIElement element, Visibility visibility)
+ {
+ visibilityBeforeRender.TryAdd(element, element.Visibility);
+ element.Visibility = visibility;
+ }
+
+ Dictionary markupGizmoVisibility = [];
+ StopCanvasTranslateAnimation();
+ double originalScaleX = canvasScale.ScaleX;
+ double originalScaleY = canvasScale.ScaleY;
+ double originalTranslateX = canvasTranslate.X;
+ double originalTranslateY = canvasTranslate.Y;
+
+ try
+ {
+ foreach (UIElement element in ShapeCanvas.Children)
+ {
+ SetVisibilityForRender(
+ element,
+ includedElements.Contains(element) ? Visibility.Visible : Visibility.Collapsed);
+ }
+
+ SetVisibilityForRender(DrawingCanvas, includeMeasurements ? Visibility.Visible : Visibility.Collapsed);
+ SetVisibilityForRender(MarkupCanvas, includeMarkup ? Visibility.Visible : Visibility.Collapsed);
+ SetVisibilityForRender(EraseMaskCanvas, Visibility.Collapsed);
+ SetVisibilityForRender(ImageResizeGrip, Visibility.Collapsed);
+
+ foreach (MarkupShapeControl control in markupShapeControls)
+ {
+ markupGizmoVisibility[control] = control.IsDragGizmoVisible;
+ control.IsDragGizmoVisible = false;
+ }
+
+ canvasScale.ScaleX = 1;
+ canvasScale.ScaleY = 1;
+ canvasTranslate.X = 0;
+ canvasTranslate.Y = 0;
+
+ ShapeCanvas.UpdateLayout();
+
+ DrawingVisual visual = new();
+ using (DrawingContext context = visual.RenderOpen())
+ {
+ VisualBrush imageBrush = new(ShapeCanvas)
+ {
+ Stretch = Stretch.Fill,
+ Viewbox = new Rect(0, 0, MainImage.ActualWidth, MainImage.ActualHeight),
+ ViewboxUnits = BrushMappingMode.Absolute,
+ Viewport = new Rect(0, 0, imageWidth, imageHeight),
+ ViewportUnits = BrushMappingMode.Absolute
+ };
+ context.DrawRectangle(imageBrush, null, new Rect(0, 0, imageWidth, imageHeight));
+ }
+
+ RenderTargetBitmap renderedImage = new(
+ imageWidth,
+ imageHeight,
+ 96,
+ 96,
+ PixelFormats.Pbgra32);
+ renderedImage.Render(visual);
+ renderedImage.Freeze();
+ return renderedImage;
+ }
+ finally
+ {
+ foreach ((UIElement element, Visibility visibility) in visibilityBeforeRender)
+ element.Visibility = visibility;
+
+ foreach ((MarkupShapeControl control, bool visible) in markupGizmoVisibility)
+ control.IsDragGizmoVisible = visible;
+
+ canvasScale.ScaleX = originalScaleX;
+ canvasScale.ScaleY = originalScaleY;
+ canvasTranslate.X = originalTranslateX;
+ canvasTranslate.Y = originalTranslateY;
+ }
+ }
+
private void SetUiForLongTask()
{
BottomPane.IsEnabled = false;
@@ -1212,7 +1533,7 @@ private async void PasteButton_Click(object sender, RoutedEventArgs e)
await OpenImagePath(tempFileName);
// Update UI
- BottomBorder.Visibility = Visibility.Visible;
+ ShowSidebar();
}
catch (Exception ex)
{
@@ -1251,7 +1572,7 @@ private async void CameraButton_Click(object sender, RoutedEventArgs e)
RemoveMeasurementControls();
await OpenImagePath(file.Path);
ViewModel.OpenedFileName = "CameraCapture-" + DateTime.Now.ToString("HH-mm-MMM-dd-yyyy");
- BottomBorder.Visibility = Visibility.Visible;
+ ShowSidebar();
}
else
{
@@ -1277,7 +1598,7 @@ private async void CameraButton_Click(object sender, RoutedEventArgs e)
private void OverlayButton_Click(object sender, RoutedEventArgs e)
{
WelcomeMessageModal.Visibility = Visibility.Collapsed;
- BottomBorder.Visibility = Visibility.Visible;
+ ShowSidebar();
MainGrid.Background = new SolidColorBrush(Colors.Transparent);
Background = new SolidColorBrush(Colors.Transparent);
ShapeCanvas.Background = new SolidColorBrush(Color.FromArgb(10, 255, 255, 255));
@@ -1357,7 +1678,7 @@ await Task.Run(async () =>
if (selectedAspectRatio?.AspectRatioEnum == AspectRatio.Original)
UpdateOriginalAspectRatioPreview();
- BottomBorder.Visibility = Visibility.Visible;
+ ShowSidebar();
SetUiForCompletedTask();
// Create a new project ID for this image
@@ -1402,35 +1723,14 @@ private async void ResetToOriginalMenuItem_Click(object sender, RoutedEventArgs
private void ShapeCanvas_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
// Only zoom when the mouse is over the canvas area so ScrollViewers elsewhere still work
- if (!MainGrid.IsMouseOver)
+ if (!MainGrid.IsMouseOver || IsOverMiniMap(e))
return;
// Get the current mouse position relative to the canvas
Point mousePosition = e.GetPosition(ShapeCanvas);
- // Calculate new scale based on wheel delta
double zoomChange = e.Delta > 0 ? ZoomFactor : -ZoomFactor;
- double newScaleX = canvasScale.ScaleX + (canvasScale.ScaleX * zoomChange);
- double newScaleY = canvasScale.ScaleY + (canvasScale.ScaleY * zoomChange);
-
- // Limit zoom to min/max values
- newScaleX = Math.Clamp(newScaleX, MinZoom, MaxZoom);
- newScaleY = Math.Clamp(newScaleY, MinZoom, MaxZoom);
-
- // Adjust the zoom center to the mouse position
- Point relativePt = mousePosition;
-
- // Calculate new transform origin
- double absoluteX = (relativePt.X * canvasScale.ScaleX) + canvasTranslate.X;
- double absoluteY = (relativePt.Y * canvasScale.ScaleY) + canvasTranslate.Y;
-
- // Calculate the new translate values to maintain mouse position
- canvasTranslate.X = absoluteX - (relativePt.X * newScaleX);
- canvasTranslate.Y = absoluteY - (relativePt.Y * newScaleY);
-
- // Apply new scale
- canvasScale.ScaleX = newScaleX;
- canvasScale.ScaleY = newScaleY;
+ ZoomAtCanvasPoint(canvasScale.ScaleX + (canvasScale.ScaleX * zoomChange), mousePosition);
e.Handled = true;
}
@@ -1528,16 +1828,6 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e)
return;
}
- // Middle mouse always initiates panning regardless of tool (quick navigation)
- if (e.ChangedButton == MouseButton.Middle)
- {
- draggingMode = DraggingMode.Panning;
- clickedPoint = e.GetPosition(this);
- ShapeCanvas.CaptureMouse();
- e.Handled = true;
- return;
- }
-
// Check if we're in the measure tab and starting a measurement
if (Mouse.LeftButton != MouseButtonState.Pressed)
{
@@ -1583,7 +1873,9 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e)
{
ShapeType = activeMarkupShapeType,
StrokeColor = markupColor,
- StrokeThickness = markupSize
+ StrokeThickness = markupSize,
+ IsDragGizmoVisible = MarkupTabItem?.IsSelected == true,
+ IsHitTestVisible = MarkupTabItem?.IsSelected == true
};
shapeControl.MeasurementPointMouseDown += MarkupShapePoint_MouseDown;
shapeControl.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested;
@@ -1603,7 +1895,8 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e)
MarkupTextControl textControl = new()
{
TextColor = markupColor,
- MarkupFontSize = markupSize * 4
+ MarkupFontSize = markupSize * 4,
+ IsHitTestVisible = MarkupTabItem?.IsSelected == true
};
textControl.RemoveControlRequested += MarkupTextControl_RemoveControlRequested;
Canvas.SetLeft(textControl, clickedPoint.X);
@@ -1998,92 +2291,456 @@ private async void FluentWindow_PreviewDrop(object sender, DragEventArgs e)
}
- if (e.Data.GetDataPresent(DataFormats.FileDrop, true))
- {
- if (e.Data.GetData(DataFormats.FileDrop, true) is not string[] fileNames || fileNames.Length == 0)
- return;
+ if (e.Data.GetDataPresent(DataFormats.FileDrop, true))
+ {
+ if (e.Data.GetData(DataFormats.FileDrop, true) is not string[] fileNames || fileNames.Length == 0)
+ return;
+
+ if (File.Exists(fileNames[0]))
+ await OpenImagePath(fileNames[0]);
+ }
+ }
+
+ private void ResetMenuItem_Click(object sender, RoutedEventArgs e)
+ {
+ ResetCanvasNavigation();
+ }
+
+ private void ResetCanvasNavigationButton_Click(object sender, RoutedEventArgs e)
+ {
+ ResetCanvasNavigation();
+ }
+
+ private void ResetCanvasNavigation()
+ {
+ StopCanvasTranslateAnimation();
+
+ canvasScale.ScaleX = 1;
+ canvasScale.ScaleY = 1;
+
+ canvasScale.CenterX = 0;
+ canvasScale.CenterY = 0;
+
+ canvasTranslate.X = 0;
+ canvasTranslate.Y = 0;
+ UpdateCanvasNavigationUi();
+ }
+
+ private void FitImageButton_Click(object sender, RoutedEventArgs e)
+ {
+ CenterAndZoomToFit();
+ }
+
+ private void FitTransformButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (TryGetActiveTransformBounds(out Rect bounds))
+ ZoomToFitBounds(bounds);
+ else
+ CenterAndZoomToFit();
+ }
+
+ private void AllowOutsideImageToggle_Changed(object sender, RoutedEventArgs e)
+ {
+ allowHandlesOutsideImage = AllowOutsideImageToggle.IsChecked == true;
+ }
+
+ private void CanvasZoomSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
+ {
+ if (!IsLoaded || isUpdatingCanvasNavigation)
+ return;
+
+ double scale = Math.Clamp(e.NewValue, MinZoom, MaxZoom);
+ if (Math.Abs(scale - canvasScale.ScaleX) < double.Epsilon)
+ return;
+
+ Point viewportCenter = new(MainGrid.ActualWidth / 2, MainGrid.ActualHeight / 2);
+ ZoomAtViewportPoint(scale, viewportCenter);
+ }
+
+ private void CanvasScale_Changed(object? sender, EventArgs e)
+ {
+ if (!IsInitialized)
+ return;
+
+ UpdateTransformVisualScale();
+ UpdateCanvasNavigationUi();
+ UpdateMiniMap();
+ }
+
+ private void CanvasTranslate_Changed(object? sender, EventArgs e)
+ {
+ if (!IsInitialized)
+ return;
+
+ UpdateMiniMap();
+ }
+
+ ///
+ /// Recomputes the visible canvas region and pushes it to the mini map overlay.
+ ///
+ private void UpdateMiniMap()
+ {
+ if (!IsInitialized)
+ return;
+
+ bool hasContent = false;
+ double scale = canvasScale.ScaleX;
+
+ if (showMiniMap
+ && scale > 0
+ && MainImage.Source is not null
+ && MainImage.ActualWidth > 0
+ && MainImage.ActualHeight > 0
+ && MainGrid.ActualWidth > 0
+ && MainGrid.ActualHeight > 0)
+ {
+ Rect viewportInCanvas = new(
+ (-CanvasOriginOffset - canvasTranslate.X) / scale,
+ (-CanvasOriginOffset - canvasTranslate.Y) / scale,
+ MainGrid.ActualWidth / scale,
+ MainGrid.ActualHeight / scale);
+
+ hasContent = CanvasMiniMap.UpdateMap(
+ MainImage.Source,
+ new Size(MainImage.ActualWidth, MainImage.ActualHeight),
+ viewportInCanvas);
+ }
+
+ CanvasMiniMap.Visibility = hasContent ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private void CanvasMiniMap_ViewportCenterRequested(object? sender, Point canvasPoint)
+ {
+ CenterViewportOnCanvasPoint(canvasPoint, animate: false);
+ }
+
+ private void UpdateCanvasNavigationUi()
+ {
+ if (!IsInitialized)
+ return;
+
+ isUpdatingCanvasNavigation = true;
+ try
+ {
+ double scale = Math.Clamp(canvasScale.ScaleX, MinZoom, MaxZoom);
+ CanvasZoomSlider.Value = scale;
+ CanvasZoomText.Text = $"{scale:P0}";
+ }
+ finally
+ {
+ isUpdatingCanvasNavigation = false;
+ }
+ }
+
+ private bool TryGetActiveTransformBounds(out Rect bounds)
+ {
+ Ellipse[] corners = [TopLeft, TopRight, BottomRight, BottomLeft];
+ if (corners.All(handle => handle.Visibility == Visibility.Visible))
+ {
+ double minX = corners.Min(handle => Canvas.GetLeft(handle) + (handle.Width / 2));
+ double minY = corners.Min(handle => Canvas.GetTop(handle) + (handle.Height / 2));
+ double maxX = corners.Max(handle => Canvas.GetLeft(handle) + (handle.Width / 2));
+ double maxY = corners.Max(handle => Canvas.GetTop(handle) + (handle.Height / 2));
+ bounds = new Rect(new Point(minX, minY), new Point(maxX, maxY));
+ return bounds.Width > 0 && bounds.Height > 0;
+ }
+
+ if (CroppingRectangle.Visibility == Visibility.Visible
+ && CroppingRectangle.ActualWidth > 0
+ && CroppingRectangle.ActualHeight > 0)
+ {
+ bounds = new Rect(
+ Canvas.GetLeft(CroppingRectangle),
+ Canvas.GetTop(CroppingRectangle),
+ CroppingRectangle.ActualWidth,
+ CroppingRectangle.ActualHeight);
+ return true;
+ }
+
+ bounds = Rect.Empty;
+ return false;
+ }
+
+ private void ZoomToFitBounds(Rect bounds)
+ {
+ if (MainGrid.ActualWidth <= 0 || MainGrid.ActualHeight <= 0 || bounds.IsEmpty)
+ return;
+
+ const double paddingFactor = 0.85;
+ double availableWidth = MainGrid.ActualWidth * paddingFactor;
+ double availableHeight = MainGrid.ActualHeight * paddingFactor;
+ double scale = Math.Clamp(
+ Math.Min(availableWidth / bounds.Width, availableHeight / bounds.Height),
+ MinZoom,
+ MaxZoom);
+
+ canvasScale.ScaleX = scale;
+ canvasScale.ScaleY = scale;
+ StopCanvasTranslateAnimation();
+ canvasTranslate.X = (MainGrid.ActualWidth / 2) - ((50 + bounds.X + (bounds.Width / 2)) * scale);
+ canvasTranslate.Y = (MainGrid.ActualHeight / 2) - ((50 + bounds.Y + (bounds.Height / 2)) * scale);
+ UpdateCanvasNavigationUi();
+ }
+
+ ///
+ /// Centers and zooms the canvas to fit the image in the viewport with padding.
+ ///
+ private void CenterAndZoomToFit()
+ {
+ if (MainImage.Source == null || MainGrid.ActualWidth == 0 || MainGrid.ActualHeight == 0)
+ return;
+
+ // Force layout update to ensure ImageGrid has rendered
+ UpdateLayout();
+
+ // Get the viewport size (the visible area in MainGrid)
+ double viewportWidth = MainGrid.ActualWidth;
+ double viewportHeight = MainGrid.ActualHeight;
+
+ // Get the image size (ImageGrid size which contains the image)
+ double imageWidth = ImageGrid.ActualWidth;
+ double imageHeight = ImageGrid.ActualHeight;
+
+ if (imageWidth == 0 || imageHeight == 0)
+ return;
+
+ ZoomToFitBounds(new Rect(0, 0, imageWidth, imageHeight));
+ }
+
+ ///
+ /// Menu item handler to center and zoom to fit the image on demand.
+ ///
+ private void CenterAndZoomToFitMenuItem_Click(object sender, RoutedEventArgs e)
+ {
+ CenterAndZoomToFit();
+ }
+
+ private void MainGrid_PreviewMouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (IsOverMiniMap(e) || IsOverCornerNavButton(e))
+ return;
+
+ if (e.ChangedButton == MouseButton.Left && isMarkupSelectMode
+ && TryStartMarkupSelectGesture(e))
+ {
+ return;
+ }
+
+ if (e.ChangedButton != MouseButton.Middle)
+ return;
+
+ draggingMode = DraggingMode.Panning;
+ clickedPoint = e.GetPosition(this);
+ MainGrid.CaptureMouse();
+ Cursor = Cursors.SizeAll;
+ e.Handled = true;
+ }
+
+ ///
+ /// Handles a left-button press while the markup "Select" tool is active. Clicking an
+ /// already-selected shape/text (as part of a multi-item selection) drags the whole
+ /// group together; clicking empty canvas starts a rubber-band marquee that can enclose
+ /// ink strokes, shapes, and text as one group; clicking a stroke is left untouched so the
+ /// native InkCanvas selection/resize behavior keeps working for ink-only selections.
+ ///
+ /// True if the gesture was handled here and should not fall through.
+ private bool TryStartMarkupSelectGesture(MouseButtonEventArgs e)
+ {
+ Point clickPoint = e.GetPosition(ShapeCanvas);
+
+ if (e.OriginalSource is DependencyObject originalSource)
+ {
+ MarkupShapeControl? hitShape = FindAncestor(originalSource);
+ MarkupTextControl? hitText = FindAncestor(originalSource);
+
+ if (hitShape is not null || hitText is not null)
+ {
+ bool alreadySelected = (hitShape is not null && selectedMarkupShapes.Contains(hitShape))
+ || (hitText is not null && selectedMarkupTexts.Contains(hitText));
+ int totalSelected = selectedMarkupShapes.Count + selectedMarkupTexts.Count
+ + MarkupCanvas.GetSelectedStrokes().Count;
+
+ if (alreadySelected && totalSelected > 1)
+ {
+ BeginMarkupGroupMove(clickPoint);
+ e.Handled = true;
+ return true;
+ }
+
+ // Single click on a (possibly unselected) item: reset any existing group
+ // selection but let the item's own click/drag/edit behavior proceed untouched.
+ ClearMarkupGroupSelection();
+ return false;
+ }
+ }
+
+ Stroke? hitStroke = null;
+ foreach (Stroke stroke in MarkupCanvas.Strokes)
+ {
+ if (stroke.HitTest(clickPoint))
+ {
+ hitStroke = stroke;
+ break;
+ }
+ }
+
+ if (hitStroke is not null)
+ {
+ // Preserve an existing mixed group only if this stroke is already part of it;
+ // otherwise a fresh ink-only interaction is starting, so drop the stale selection.
+ bool partOfCurrentGroup = MarkupCanvas.GetSelectedStrokes().Contains(hitStroke)
+ && (selectedMarkupShapes.Count > 0 || selectedMarkupTexts.Count > 0);
+ if (!partOfCurrentGroup)
+ ClearMarkupGroupSelection();
+ return false; // let the native InkCanvas Select behavior handle strokes as before
+ }
+
+ BeginMarkupMarquee(clickPoint);
+ e.Handled = true;
+ return true;
+ }
+
+ private static bool IsOverMiniMap(RoutedEventArgs e)
+ => e.OriginalSource is DependencyObject source && FindAncestor(source) is not null;
+
+ private static bool IsOverCornerNavButton(RoutedEventArgs e)
+ => e.OriginalSource is DependencyObject source && FindAncestor(source) is not null;
+
+ private static T? FindAncestor(DependencyObject? current) where T : DependencyObject
+ {
+ while (current is not null)
+ {
+ if (current is T match)
+ return match;
+ current = current is Visual ? VisualTreeHelper.GetParent(current) : null;
+ }
+ return null;
+ }
+
+ private void CanvasContextMenu_Opened(object sender, RoutedEventArgs e)
+ {
+ bool isBarVisible = CanvasNavigationBar.Visibility == Visibility.Visible;
+
+ ToggleCanvasNavigationMenuItem.IsEnabled = ViewModel.HasImage;
+ ToggleCanvasNavigationMenuItem.IsChecked = isBarVisible;
- if (File.Exists(fileNames[0]))
- await OpenImagePath(fileNames[0]);
- }
+ ToggleCanvasNavigationBarMenuItem.IsEnabled = ViewModel.HasImage;
+ ToggleCanvasNavigationBarMenuItem.IsChecked = isBarVisible;
+
+ ToggleMiniMapMenuItem.IsEnabled = ViewModel.HasImage;
+ ToggleMiniMapMenuItem.IsChecked = showMiniMap;
+
+ ToggleMiniMapBarMenuItem.IsEnabled = ViewModel.HasImage;
+ ToggleMiniMapBarMenuItem.IsChecked = showMiniMap;
}
- private void ResetMenuItem_Click(object sender, RoutedEventArgs e)
+ private void ToggleMiniMapMenuItem_Click(object sender, RoutedEventArgs e)
{
- canvasScale.ScaleX = 1;
- canvasScale.ScaleY = 1;
+ showMiniMap = sender is System.Windows.Controls.MenuItem { IsCheckable: true } menuItem
+ ? menuItem.IsChecked
+ : !showMiniMap;
- canvasScale.CenterX = 0;
- canvasScale.CenterY = 0;
+ ToggleMiniMapMenuItem.IsChecked = showMiniMap;
+ ToggleMiniMapBarMenuItem.IsChecked = showMiniMap;
- canvasTranslate.X = 0;
- canvasTranslate.Y = 0;
+ UpdateMiniMap();
}
- ///
- /// Centers and zooms the canvas to fit the image in the viewport with padding.
- ///
- private void CenterAndZoomToFit()
+ private void ToggleCanvasNavigationMenuItem_Click(object sender, RoutedEventArgs e)
{
- if (MainImage.Source == null || MainGrid.ActualWidth == 0 || MainGrid.ActualHeight == 0)
- return;
+ bool showBar = sender is System.Windows.Controls.MenuItem { IsCheckable: true } menuItem
+ ? menuItem.IsChecked
+ : CanvasNavigationBar.Visibility != Visibility.Visible;
- // Force layout update to ensure ImageGrid has rendered
- UpdateLayout();
+ if (showBar)
+ CanvasNavigationBar.ClearValue(UIElement.VisibilityProperty);
+ else
+ CanvasNavigationBar.Visibility = Visibility.Collapsed;
- // Get the viewport size (the visible area in MainGrid)
- double viewportWidth = MainGrid.ActualWidth;
- double viewportHeight = MainGrid.ActualHeight;
+ // Keep both menu items (canvas background + canvas bar) in sync.
+ ToggleCanvasNavigationMenuItem.IsChecked = showBar;
+ ToggleCanvasNavigationBarMenuItem.IsChecked = showBar;
+ }
- // Get the image size (ImageGrid size which contains the image)
- double imageWidth = ImageGrid.ActualWidth;
- double imageHeight = ImageGrid.ActualHeight;
+ private void ShowSidebar()
+ {
+ SidebarToggleButton.Visibility = Visibility.Visible;
+ SidebarToggleButton.IsChecked = true;
+ BottomBorder.Visibility = Visibility.Visible;
+ SidebarColumn.Width = new GridLength(DefaultSidebarWidth);
+ SidebarToggleText.Text = "Hide tools";
+ SidebarToggleButton.ToolTip = "Collapse tools sidebar";
+ }
- if (imageWidth == 0 || imageHeight == 0)
- return;
+ private void HideSidebar()
+ {
+ BottomBorder.Visibility = Visibility.Collapsed;
+ SidebarColumn.Width = new GridLength(0);
+ SidebarToggleButton.IsChecked = false;
+ SidebarToggleButton.Visibility = Visibility.Collapsed;
+ }
- // Add padding (10% on each side)
- double paddingFactor = 0.9; // Use 90% of viewport to leave 10% padding
- double availableWidth = viewportWidth * paddingFactor;
- double availableHeight = viewportHeight * paddingFactor;
+ private void SidebarToggleButton_Checked(object sender, RoutedEventArgs e)
+ {
+ if (!IsInitialized)
+ return;
- // Calculate scale factors to fit the image in the viewport
- double scaleX = availableWidth / imageWidth;
- double scaleY = availableHeight / imageHeight;
+ BottomBorder.Visibility = Visibility.Visible;
+ SidebarColumn.Width = new GridLength(DefaultSidebarWidth);
+ SidebarToggleText.Text = "Hide tools";
+ SidebarToggleButton.ToolTip = "Collapse tools sidebar";
+ }
- // Use the smaller scale to ensure the entire image fits
- double scale = Math.Min(scaleX, scaleY);
+ private void SidebarToggleButton_Unchecked(object sender, RoutedEventArgs e)
+ {
+ if (!IsInitialized)
+ return;
- // Clamp scale to min/max zoom limits
- scale = Math.Clamp(scale, MinZoom, MaxZoom);
+ BottomBorder.Visibility = Visibility.Collapsed;
+ SidebarColumn.Width = new GridLength(0);
+ SidebarToggleText.Text = "Show tools";
+ SidebarToggleButton.ToolTip = "Show tools sidebar";
+ }
- // Apply the scale
- canvasScale.ScaleX = scale;
- canvasScale.ScaleY = scale;
+ private void MainGrid_PreviewMouseUp(object sender, MouseButtonEventArgs e)
+ {
+ if (e.ChangedButton != MouseButton.Middle || draggingMode != DraggingMode.Panning)
+ return;
- // Calculate the scaled image dimensions
- double scaledImageWidth = imageWidth * scale;
- double scaledImageHeight = imageHeight * scale;
+ draggingMode = DraggingMode.None;
+ MainGrid.ReleaseMouseCapture();
+ Cursor = null;
+ e.Handled = true;
+ }
- // Calculate translation to center the image
- // The canvas has a 50,50 margin, so we need to account for that
- double canvasMarginX = 50;
- double canvasMarginY = 50;
+ private void ZoomAtCanvasPoint(double scale, Point canvasPoint)
+ {
+ StopCanvasTranslateAnimation();
- // Center the scaled image in the viewport
- double translateX = (viewportWidth - scaledImageWidth) / 2 - (canvasMarginX * scale);
- double translateY = (viewportHeight - scaledImageHeight) / 2 - (canvasMarginY * scale);
+ double originalScale = canvasScale.ScaleX;
+ if (originalScale <= 0)
+ return;
- canvasTranslate.X = translateX;
- canvasTranslate.Y = translateY;
+ double targetScale = Math.Clamp(scale, MinZoom, MaxZoom);
+ double absoluteX = (canvasPoint.X * originalScale) + canvasTranslate.X;
+ double absoluteY = (canvasPoint.Y * originalScale) + canvasTranslate.Y;
+ canvasTranslate.X = absoluteX - (canvasPoint.X * targetScale);
+ canvasTranslate.Y = absoluteY - (canvasPoint.Y * targetScale);
+ canvasScale.ScaleX = targetScale;
+ canvasScale.ScaleY = targetScale;
+ UpdateCanvasNavigationUi();
}
- ///
- /// Menu item handler to center and zoom to fit the image on demand.
- ///
- private void CenterAndZoomToFitMenuItem_Click(object sender, RoutedEventArgs e)
+ private void ZoomAtViewportPoint(double scale, Point viewportPoint)
{
- CenterAndZoomToFit();
+ double currentScale = canvasScale.ScaleX;
+ if (currentScale <= 0)
+ return;
+
+ Point canvasPoint = new(
+ (viewportPoint.X - 50 - canvasTranslate.X) / currentScale,
+ (viewportPoint.Y - 50 - canvasTranslate.Y) / currentScale);
+ ZoomAtCanvasPoint(scale, canvasPoint);
}
private void WhitePointPickerToggle_Checked(object sender, RoutedEventArgs e)
@@ -2493,6 +3150,8 @@ private void ShowTransformControls()
foreach (UIElement element in _polygonElements)
element.Visibility = Visibility.Visible;
+
+ RefreshCornerNavButtons();
}
private void HideTransformControls()
@@ -2504,6 +3163,8 @@ private void HideTransformControls()
element.Visibility = Visibility.Collapsed;
lines?.Visibility = Visibility.Collapsed;
+
+ RefreshCornerNavButtons();
}
#region Tri-Fold Correction
@@ -2544,6 +3205,8 @@ private void ShowTriFoldControls()
// Build the unified tri-fold polygon
DrawTriFoldGuideLines();
+
+ RefreshCornerNavButtons();
}
private void HideTriFoldControls()
@@ -2562,6 +3225,8 @@ private void HideTriFoldControls()
element.Visibility = Visibility.Collapsed;
RemoveTriFoldGuideLines();
+
+ RefreshCornerNavButtons();
}
private void ResetTriFoldMarkers()
@@ -2849,6 +3514,8 @@ private void PositionUnWarpMarkers(QuadrilateralDetector.DetectedQuadrilateral q
lines?.Visibility = Visibility.Collapsed;
UpdateUnWarpGuideCurves();
+
+ UpdateCornerNavButtons();
}
private void ShowUnWarpControls()
@@ -2874,6 +3541,8 @@ private void ShowUnWarpControls()
lines?.Visibility = Visibility.Collapsed;
DrawUnWarpGuideCurves();
+
+ RefreshCornerNavButtons();
}
private void HideUnWarpControls()
@@ -2896,6 +3565,8 @@ private void HideUnWarpControls()
element.Visibility = Visibility.Collapsed;
RemoveUnWarpGuideCurves();
+
+ RefreshCornerNavButtons();
}
private void ResetUnWarpMarkers()
@@ -3788,6 +4459,8 @@ private void PositionCornerMarkers(Helpers.QuadrilateralDetector.DetectedQuadril
// Update the polyline
DrawPolyLine();
+
+ UpdateCornerNavButtons();
}
private void ImageResizeGrip_MouseDown(object sender, MouseButtonEventArgs e)
@@ -4321,11 +4994,10 @@ private async void MeasurementControl_SetRealWorldLengthRequested(object sender,
Title = "Set Real World Length",
Content = inputTextBox,
PrimaryButtonText = "Apply",
- CloseButtonText = "Cancel"
+ CloseButtonText = "Cancel",
+ // Show the dialog and handle the result
+ DialogHost = Presenter
};
-
- // Show the dialog and handle the result
- dialog.DialogHost = Presenter;
dialog.Closing += (s, args) =>
{
// Check if the primary button was clicked and input is valid
@@ -5054,6 +5726,8 @@ private async Task LoadMeasurementPackageAsync(string fileName)
{
MarkupShapeControl control = new();
control.FromDto(dto);
+ control.IsDragGizmoVisible = MarkupTabItem?.IsSelected == true;
+ control.IsHitTestVisible = MarkupTabItem?.IsSelected == true;
control.MeasurementPointMouseDown += MarkupShapePoint_MouseDown;
control.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested;
markupShapeControls.Add(control);
@@ -5065,6 +5739,7 @@ private async Task LoadMeasurementPackageAsync(string fileName)
{
MarkupTextControl control = new();
control.FromDto(dto);
+ control.IsHitTestVisible = MarkupTabItem?.IsSelected == true;
control.RemoveControlRequested += MarkupTextControl_RemoveControlRequested;
control.EditCommitted += MarkupTextControl_EditCommitted;
control.TextMoved += MarkupTextControl_TextMoved;
@@ -5316,6 +5991,8 @@ private void ResetTransformCornerMarkers()
// Rebuild the polyline so it matches the reset marker positions
DrawPolyLine();
+
+ UpdateCornerNavButtons();
}
private void ResetApplicationState()
@@ -5344,12 +6021,13 @@ private void ResetApplicationState()
HideResizeControls();
HideObjectEraseControls();
HideThresholdControls();
- BottomBorder.Visibility = Visibility.Collapsed;
+ HideSidebar();
WelcomeMessageModal.Visibility = Visibility.Visible;
OpenFolderButton.IsEnabled = false;
Save.IsEnabled = false;
// Reset the canvas transform
+ StopCanvasTranslateAnimation();
canvasScale.ScaleX = 1;
canvasScale.ScaleY = 1;
canvasScale.CenterX = 0;
@@ -5752,13 +6430,32 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e)
}
}
- // Handle Delete key for selected markup ink strokes
+ // Handle Delete key for selected markup ink strokes, shapes, and text
if (e.Key == Key.Delete && isMarkupSelectMode)
{
- StrokeCollection selected = MarkupCanvas.GetSelectedStrokes();
- if (selected.Count > 0)
+ bool deletedAnything = false;
+
+ if (MarkupCanvas.GetSelectedStrokes().Count > 0)
{
DeleteSelectedMarkupStrokes();
+ deletedAnything = true;
+ }
+
+ foreach (MarkupShapeControl shape in selectedMarkupShapes.ToList())
+ {
+ MarkupShapeControl_RemoveControlRequested(shape, EventArgs.Empty);
+ deletedAnything = true;
+ }
+
+ foreach (MarkupTextControl text in selectedMarkupTexts.ToList())
+ {
+ MarkupTextControl_RemoveControlRequested(text, EventArgs.Empty);
+ deletedAnything = true;
+ }
+
+ if (deletedAnything)
+ {
+ ClearMarkupGroupSelection();
e.Handled = true;
return;
}
@@ -6306,46 +7003,18 @@ private void HidePixelZoom()
/// Point in image pixel coordinates
private Point ConvertCanvasToImageCoordinates(Point canvasPoint)
{
- if (MainImage.Source == null)
+ if (MainImage.Source is not BitmapSource source
+ || MainImage.ActualWidth <= 0
+ || MainImage.ActualHeight <= 0)
return new Point(0, 0);
- try
- {
- // Get the transform from canvas to image
- GeneralTransform transform = ShapeCanvas.TransformToVisual(MainImage);
- Point imagePoint = transform.Transform(canvasPoint);
-
- // MainImage might have its own transform/scale, so we need to map to actual pixels
- double imageWidth = MainImage.Source.Width;
- double imageHeight = MainImage.Source.Height;
- double actualWidth = MainImage.ActualWidth;
- double actualHeight = MainImage.ActualHeight;
-
- // Calculate scale based on Stretch mode
- double scaleX = imageWidth / actualWidth;
- double scaleY = imageHeight / actualHeight;
-
- // For Uniform stretch, use the same scale for both dimensions
- if (MainImage.Stretch == Stretch.Uniform)
- {
- double scale = Math.Max(scaleX, scaleY);
- scaleX = scaleY = scale;
- }
-
- // Convert to pixel coordinates
- double pixelX = imagePoint.X * scaleX;
- double pixelY = imagePoint.Y * scaleY;
-
- // Clamp to image bounds
- pixelX = Math.Max(0, Math.Min(imageWidth - 1, pixelX));
- pixelY = Math.Max(0, Math.Min(imageHeight - 1, pixelY));
-
- return new Point(pixelX, pixelY);
- }
- catch (Exception)
- {
- return new Point(0, 0);
- }
+ // ImageGrid is anchored at the ShapeCanvas origin, so these logical canvas
+ // coordinates stay independent of the viewport's pan and zoom transform.
+ double pixelX = canvasPoint.X * source.PixelWidth / MainImage.ActualWidth;
+ double pixelY = canvasPoint.Y * source.PixelHeight / MainImage.ActualHeight;
+ return new Point(
+ Math.Clamp(pixelX, 0, source.PixelWidth - 1),
+ Math.Clamp(pixelY, 0, source.PixelHeight - 1));
}
///
@@ -6409,9 +7078,13 @@ private void ToolsTabControl_SelectionChanged(object sender, SelectionChangedEve
if (e.Source is not TabControl)
return;
+ bool measurementTabActive = MeasureTabItem?.IsSelected == true;
bool markupTabActive = MarkupTabItem?.IsSelected == true;
if (!markupTabActive)
DeactivateAllMarkupTools();
+
+ SetMeasurementDragGizmosVisibility(measurementTabActive);
+ SetMarkupDragGizmosVisibility(markupTabActive);
}
private void DeactivateAllMarkupTools()
@@ -6429,8 +7102,99 @@ private void DeactivateAllMarkupTools()
UncheckMarkupAllBut();
}
+ private void SetMarkupDragGizmosVisibility(bool visible)
+ {
+ if (!visible)
+ MarkupCanvas.Select(new StrokeCollection());
+
+ foreach (MarkupShapeControl control in markupShapeControls)
+ {
+ control.IsDragGizmoVisible = visible;
+ control.IsHitTestVisible = visible;
+ }
+
+ foreach (MarkupTextControl control in markupTextControls)
+ control.IsHitTestVisible = visible;
+ }
+
+ private void SetMeasurementDragGizmosVisibility(bool visible)
+ {
+ if (!visible)
+ DrawingCanvas.Select(new StrokeCollection());
+
+ foreach (DistanceMeasurementControl control in measurementTools)
+ {
+ SetMeasurementGizmoState(control, visible);
+ control.IsHitTestVisible = visible;
+ }
+
+ foreach (AngleMeasurementControl control in angleMeasurementTools)
+ {
+ SetMeasurementGizmoState(control, visible);
+ control.IsHitTestVisible = visible;
+ }
+
+ foreach (RectangleMeasurementControl control in rectangleMeasurementTools)
+ {
+ SetMeasurementGizmoState(control, visible);
+ control.IsHitTestVisible = visible;
+ }
+
+ foreach (PolygonMeasurementControl control in polygonMeasurementTools)
+ {
+ SetMeasurementGizmoState(control, visible);
+ control.IsHitTestVisible = visible;
+ }
+
+ foreach (CircleMeasurementControl control in circleMeasurementTools)
+ {
+ SetMeasurementGizmoState(control, visible);
+ control.IsHitTestVisible = visible;
+ }
+
+ foreach (VerticalLineControl control in verticalLineControls)
+ control.IsHitTestVisible = visible;
+
+ foreach (HorizontalLineControl control in horizontalLineControls)
+ control.IsHitTestVisible = visible;
+
+ foreach (StrokeLengthDisplay control in ShapeCanvas.Children.OfType())
+ control.IsHitTestVisible = visible;
+ }
+
+ private static void SetMeasurementGizmoState(T control, bool visible)
+ where T : class
+ {
+ switch (control)
+ {
+ case DistanceMeasurementControl distance:
+ distance.IsEndpointCapVisible = !visible;
+ distance.IsDragGizmoVisible = true;
+ break;
+ case AngleMeasurementControl angle:
+ angle.IsEndpointCapVisible = !visible;
+ angle.IsDragGizmoVisible = true;
+ break;
+ case RectangleMeasurementControl rectangle:
+ rectangle.IsEndpointCapVisible = !visible;
+ rectangle.IsDragGizmoVisible = true;
+ break;
+ case PolygonMeasurementControl polygon:
+ polygon.IsEndpointCapVisible = !visible;
+ polygon.IsDragGizmoVisible = true;
+ break;
+ case CircleMeasurementControl circle:
+ circle.IsEndpointCapVisible = !visible;
+ circle.IsDragGizmoVisible = true;
+ break;
+ }
+ }
+
private void UncheckMarkupAllBut(ToggleButton? keep = null)
{
+ // Switching markup tools drops any active multi-item group selection
+ ClearMarkupGroupSelection();
+
if (MarkupToolsPanel is null || MarkupShapeToolsPanel is null) return;
foreach (ToggleButton btn in MarkupToolsPanel.Children.OfType())
if (btn != keep) btn.IsChecked = false;
@@ -6526,6 +7290,217 @@ private void MarkupSelectToggle_Checked(object sender, RoutedEventArgs e)
UncheckMarkupAllBut(sender as ToggleButton);
}
+ #region Markup Group Selection
+
+ ///
+ /// Starts a rubber-band marquee (in ShapeCanvas coordinates) that, on release, selects
+ /// every ink stroke, shape, and text control it fully encloses as one group.
+ ///
+ private void BeginMarkupMarquee(Point startPoint)
+ {
+ markupMarqueeStartPoint = startPoint;
+ draggingMode = DraggingMode.MarkupGroupSelect;
+ MarkupMarqueeRectangle.Visibility = Visibility.Visible;
+ UpdateMarkupMarqueeVisual(startPoint);
+ CaptureMouse();
+ }
+
+ private void UpdateMarkupMarqueeVisual(Point currentPoint)
+ {
+ if (markupMarqueeStartPoint is not Point start)
+ return;
+
+ double x = Math.Min(start.X, currentPoint.X);
+ double y = Math.Min(start.Y, currentPoint.Y);
+ double width = Math.Abs(currentPoint.X - start.X);
+ double height = Math.Abs(currentPoint.Y - start.Y);
+
+ Canvas.SetLeft(MarkupMarqueeRectangle, x);
+ Canvas.SetTop(MarkupMarqueeRectangle, y);
+ MarkupMarqueeRectangle.Width = width;
+ MarkupMarqueeRectangle.Height = height;
+ }
+
+ private void FinishMarkupMarquee(Point endPoint)
+ {
+ MarkupMarqueeRectangle.Visibility = Visibility.Collapsed;
+
+ if (markupMarqueeStartPoint is not Point start)
+ return;
+
+ markupMarqueeStartPoint = null;
+
+ Rect marqueeRect = new(start, endPoint);
+
+ // Ignore accidental micro-drags (treat them as a deselect click on empty canvas)
+ if (marqueeRect.Width < 2 && marqueeRect.Height < 2)
+ {
+ ClearMarkupGroupSelection();
+ return;
+ }
+
+ StrokeCollection enclosedStrokes = [];
+ foreach (Stroke stroke in MarkupCanvas.Strokes)
+ if (marqueeRect.Contains(stroke.GetBounds()))
+ enclosedStrokes.Add(stroke);
+
+ List enclosedShapes = [.. markupShapeControls.Where(s => marqueeRect.Contains(GetMarkupShapeBounds(s)))];
+ List enclosedTexts = [.. markupTextControls.Where(t => marqueeRect.Contains(GetMarkupTextBounds(t)))];
+
+ ApplyMarkupGroupSelection(enclosedStrokes, enclosedShapes, enclosedTexts);
+ }
+
+ private static Rect GetMarkupShapeBounds(MarkupShapeControl shape)
+ {
+ (Point p1, Point p2) = shape.GetPoints();
+ Rect rect = new(p1, p2);
+ rect.Inflate(6, 6);
+ return rect;
+ }
+
+ private static Rect GetMarkupTextBounds(MarkupTextControl text)
+ {
+ double left = Canvas.GetLeft(text);
+ double top = Canvas.GetTop(text);
+ double width = text.ActualWidth > 0 ? text.ActualWidth : 40;
+ double height = text.ActualHeight > 0 ? text.ActualHeight : 20;
+ return new Rect(left, top, width, height);
+ }
+
+ private void ApplyMarkupGroupSelection(StrokeCollection strokes, List shapes, List texts)
+ {
+ ClearMarkupGroupSelection();
+
+ MarkupCanvas.Select(strokes);
+
+ foreach (MarkupShapeControl shape in shapes)
+ {
+ selectedMarkupShapes.Add(shape);
+ shape.IsDragGizmoVisible = true;
+ }
+
+ foreach (MarkupTextControl text in texts)
+ {
+ selectedMarkupTexts.Add(text);
+ AddMarkupSelectionHighlight(text);
+ }
+ }
+
+ private void AddMarkupSelectionHighlight(MarkupTextControl text)
+ {
+ Rect bounds = GetMarkupTextBounds(text);
+ System.Windows.Shapes.Rectangle highlight = new()
+ {
+ Width = bounds.Width + 8,
+ Height = bounds.Height + 8,
+ Stroke = System.Windows.Media.Brushes.DeepSkyBlue,
+ StrokeThickness = 1.5,
+ StrokeDashArray = [3, 2],
+ Fill = System.Windows.Media.Brushes.Transparent,
+ IsHitTestVisible = false,
+ Tag = text
+ };
+ Canvas.SetLeft(highlight, bounds.X - 4);
+ Canvas.SetTop(highlight, bounds.Y - 4);
+ Panel.SetZIndex(highlight, 2000);
+ ShapeCanvas.Children.Add(highlight);
+ markupSelectionHighlights.Add(highlight);
+ }
+
+ private void RefreshMarkupSelectionHighlights()
+ {
+ foreach (System.Windows.Shapes.Rectangle highlight in markupSelectionHighlights)
+ {
+ if (highlight.Tag is not MarkupTextControl text) continue;
+ Rect bounds = GetMarkupTextBounds(text);
+ Canvas.SetLeft(highlight, bounds.X - 4);
+ Canvas.SetTop(highlight, bounds.Y - 4);
+ }
+ }
+
+ ///
+ /// Clears the current multi-item markup selection (ink strokes, shapes, and text) along
+ /// with its visual affordances. Safe to call even when nothing is selected.
+ ///
+ private void ClearMarkupGroupSelection()
+ {
+ foreach (MarkupShapeControl shape in selectedMarkupShapes)
+ shape.IsDragGizmoVisible = MarkupTabItem?.IsSelected == true;
+ selectedMarkupShapes.Clear();
+
+ selectedMarkupTexts.Clear();
+
+ foreach (System.Windows.Shapes.Rectangle highlight in markupSelectionHighlights)
+ ShapeCanvas.Children.Remove(highlight);
+ markupSelectionHighlights.Clear();
+
+ if (MarkupCanvas.GetSelectedStrokes().Count > 0)
+ MarkupCanvas.Select(new StrokeCollection());
+ }
+
+ private void BeginMarkupGroupMove(Point startPoint)
+ {
+ markupGroupDragLastPoint = startPoint;
+ markupGroupDragTotalDeltaX = 0;
+ markupGroupDragTotalDeltaY = 0;
+ markupGroupMoveStrokes = new StrokeCollection(MarkupCanvas.GetSelectedStrokes());
+ markupGroupMoveShapes = [.. selectedMarkupShapes];
+ markupGroupMoveTexts = [.. selectedMarkupTexts];
+ draggingMode = DraggingMode.MarkupGroupMove;
+ CaptureMouse();
+ }
+
+ private void ApplyMarkupGroupDelta(double deltaX, double deltaY)
+ {
+ if (markupGroupMoveStrokes is { Count: > 0 })
+ {
+ Matrix m = new();
+ m.Translate(deltaX, deltaY);
+ foreach (Stroke stroke in markupGroupMoveStrokes)
+ stroke.Transform(m, false);
+ }
+
+ if (markupGroupMoveShapes is not null)
+ {
+ foreach (MarkupShapeControl shape in markupGroupMoveShapes)
+ {
+ (Point p1, Point p2) = shape.GetPoints();
+ shape.MovePoint(0, new Point(p1.X + deltaX, p1.Y + deltaY));
+ shape.MovePoint(1, new Point(p2.X + deltaX, p2.Y + deltaY));
+ }
+ }
+
+ if (markupGroupMoveTexts is not null)
+ {
+ foreach (MarkupTextControl text in markupGroupMoveTexts)
+ {
+ Canvas.SetLeft(text, Canvas.GetLeft(text) + deltaX);
+ Canvas.SetTop(text, Canvas.GetTop(text) + deltaY);
+ }
+ }
+
+ RefreshMarkupSelectionHighlights();
+ }
+
+ private void FinishMarkupGroupMove()
+ {
+ if (Math.Abs(markupGroupDragTotalDeltaX) > 0.01 || Math.Abs(markupGroupDragTotalDeltaY) > 0.01)
+ {
+ UndoRedo.AddUndo(new MarkupGroupMovedItem(
+ markupGroupMoveStrokes ?? [],
+ markupGroupMoveShapes ?? [],
+ markupGroupMoveTexts ?? [],
+ markupGroupDragTotalDeltaX,
+ markupGroupDragTotalDeltaY));
+ }
+
+ markupGroupMoveStrokes = null;
+ markupGroupMoveShapes = null;
+ markupGroupMoveTexts = null;
+ }
+
+ #endregion Markup Group Selection
+
private void MarkupLineToggle_Checked(object sender, RoutedEventArgs e)
{
activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Line;
@@ -6701,7 +7676,33 @@ private void MarkupCanvas_SelectionMoved(object sender, EventArgs e)
double deltaY = newBounds.Y - _selectionBoundsBeforeMove.Value.Y;
if (Math.Abs(deltaX) > 0.01 || Math.Abs(deltaY) > 0.01)
- UndoRedo.AddUndo(new MarkupStrokeMovedItem(_strokesBeforeMove, deltaX, deltaY));
+ {
+ // If shapes/text were also part of the group selection (from a marquee), move
+ // them by the same delta so dragging the native ink adorner moves the whole group
+ if (selectedMarkupShapes.Count > 0 || selectedMarkupTexts.Count > 0)
+ {
+ List coShapes = [.. selectedMarkupShapes];
+ List coTexts = [.. selectedMarkupTexts];
+ foreach (MarkupShapeControl shape in coShapes)
+ {
+ (Point p1, Point p2) = shape.GetPoints();
+ shape.MovePoint(0, new Point(p1.X + deltaX, p1.Y + deltaY));
+ shape.MovePoint(1, new Point(p2.X + deltaX, p2.Y + deltaY));
+ }
+ foreach (MarkupTextControl text in coTexts)
+ {
+ Canvas.SetLeft(text, Canvas.GetLeft(text) + deltaX);
+ Canvas.SetTop(text, Canvas.GetTop(text) + deltaY);
+ }
+ RefreshMarkupSelectionHighlights();
+
+ UndoRedo.AddUndo(new MarkupGroupMovedItem(_strokesBeforeMove, coShapes, coTexts, deltaX, deltaY));
+ }
+ else
+ {
+ UndoRedo.AddUndo(new MarkupStrokeMovedItem(_strokesBeforeMove, deltaX, deltaY));
+ }
+ }
_strokesBeforeMove = null;
_selectionBoundsBeforeMove = null;
diff --git a/MagickCrop/Models/DraggingMode.cs b/MagickCrop/Models/DraggingMode.cs
index 47a4091..326c2a9 100644
--- a/MagickCrop/Models/DraggingMode.cs
+++ b/MagickCrop/Models/DraggingMode.cs
@@ -17,5 +17,7 @@ public enum DraggingMode
EdgeCorrectionDragging,
GridStraightenDragging,
MarkupShape,
- MarkupText
+ MarkupText,
+ MarkupGroupSelect,
+ MarkupGroupMove
}
diff --git a/MagickCrop/Models/LensCorrectionSettings.cs b/MagickCrop/Models/LensCorrectionSettings.cs
new file mode 100644
index 0000000..54be4d0
--- /dev/null
+++ b/MagickCrop/Models/LensCorrectionSettings.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace MagickCrop.Models;
+
+public class LensCorrectionSettings
+{
+ // Barrel coefficients
+ public double A { get; set; }
+ public double B { get; set; }
+ public double C { get; set; }
+
+ // For future use: independent X/Y coefficients
+ public double? Ax { get; set; }
+ public double? Ay { get; set; }
+
+ // Derived D coefficient: keep center scale
+ public double D => 1.0 - A - B - C;
+
+ public bool IsIdentity => Math.Abs(A) < 1e-9 && Math.Abs(B) < 1e-9 && Math.Abs(C) < 1e-9;
+
+ public void Reset()
+ {
+ A = 0.0;
+ B = 0.0;
+ C = 0.0;
+ Ax = null;
+ Ay = null;
+ }
+}
diff --git a/MagickCrop/Models/SaveOptions.cs b/MagickCrop/Models/SaveOptions.cs
index 496dcef..26fbbfb 100644
--- a/MagickCrop/Models/SaveOptions.cs
+++ b/MagickCrop/Models/SaveOptions.cs
@@ -11,4 +11,19 @@ public class SaveOptions
public int Width { get; set; }
public int Height { get; set; }
public bool MaintainAspectRatio { get; set; }
+ public bool IncludeMarkup { get; set; }
+ public bool IncludeMeasurements { get; set; }
+
+ public SaveOptions Clone() => new()
+ {
+ Format = Format,
+ Extension = Extension,
+ Quality = Quality,
+ Resize = Resize,
+ Width = Width,
+ Height = Height,
+ MaintainAspectRatio = MaintainAspectRatio,
+ IncludeMarkup = IncludeMarkup,
+ IncludeMeasurements = IncludeMeasurements
+ };
}
diff --git a/MagickCrop/Models/UndoRedo.cs b/MagickCrop/Models/UndoRedo.cs
index 84ade88..3e1bf0a 100644
--- a/MagickCrop/Models/UndoRedo.cs
+++ b/MagickCrop/Models/UndoRedo.cs
@@ -10,7 +10,7 @@
namespace MagickCrop;
-public class UndoRedo : INotifyPropertyChanged
+public partial class UndoRedo : INotifyPropertyChanged
{
private readonly Stack _undoStack = new();
private readonly Stack _redoStack = new();
@@ -295,6 +295,65 @@ public override string Redo()
}
}
+public class MarkupGroupMovedItem : UndoRedoItem
+{
+ private readonly StrokeCollection _strokes;
+ private readonly List _shapes;
+ private readonly List _texts;
+ private readonly double _deltaX;
+ private readonly double _deltaY;
+
+ public MarkupGroupMovedItem(
+ StrokeCollection strokes,
+ List shapes,
+ List texts,
+ double deltaX,
+ double deltaY)
+ {
+ _strokes = new StrokeCollection(strokes);
+ _shapes = shapes;
+ _texts = texts;
+ _deltaX = deltaX;
+ _deltaY = deltaY;
+ }
+
+ public override string Undo()
+ {
+ Apply(-_deltaX, -_deltaY);
+ return string.Empty;
+ }
+
+ public override string Redo()
+ {
+ Apply(_deltaX, _deltaY);
+ return string.Empty;
+ }
+
+ private void Apply(double deltaX, double deltaY)
+ {
+ if (_strokes.Count > 0)
+ {
+ Matrix m = new();
+ m.Translate(deltaX, deltaY);
+ foreach (Stroke s in _strokes)
+ s.Transform(m, false);
+ }
+
+ foreach (MarkupShapeControl shape in _shapes)
+ {
+ (Point p1, Point p2) = shape.GetPoints();
+ shape.MovePoint(0, new Point(p1.X + deltaX, p1.Y + deltaY));
+ shape.MovePoint(1, new Point(p2.X + deltaX, p2.Y + deltaY));
+ }
+
+ foreach (MarkupTextControl text in _texts)
+ {
+ Canvas.SetLeft(text, Canvas.GetLeft(text) + deltaX);
+ Canvas.SetTop(text, Canvas.GetTop(text) + deltaY);
+ }
+ }
+}
+
public class MarkupStrokeDeletedItem : UndoRedoItem
{
private readonly InkCanvas _canvas;
@@ -333,14 +392,14 @@ public MarkupStrokePropertiesChangedItem(List<(Stroke, DrawingAttributes, Drawin
public override string Undo()
{
- foreach (var (stroke, before, _) in _changes)
+ foreach ((Stroke? stroke, DrawingAttributes? before, DrawingAttributes _) in _changes)
stroke.DrawingAttributes = before;
return string.Empty;
}
public override string Redo()
{
- foreach (var (stroke, _, after) in _changes)
+ foreach ((Stroke? stroke, DrawingAttributes _, DrawingAttributes? after) in _changes)
stroke.DrawingAttributes = after;
return string.Empty;
}
diff --git a/MagickCrop/Resources/LensProfiles.json b/MagickCrop/Resources/LensProfiles.json
new file mode 100644
index 0000000..428082b
--- /dev/null
+++ b/MagickCrop/Resources/LensProfiles.json
@@ -0,0 +1,9 @@
+[
+ { "Key": "GoPro", "A": 0.0, "B": -0.5, "C": 0.0 },
+ { "Key": "DJI", "A": 0.0, "B": -0.4, "C": 0.0 },
+ { "Key": "Canon", "A": 0.0, "B": -0.15, "C": 0.0 },
+ { "Key": "Nikon", "A": 0.0, "B": -0.12, "C": 0.0 },
+ { "Key": "Sony", "A": 0.0, "B": -0.12, "C": 0.0 },
+ { "Key": "iPhone", "A": 0.0, "B": -0.08, "C": 0.0 },
+ { "Key": "Wide", "A": 0.0, "B": -0.25, "C": 0.0 }
+]
diff --git a/MagickCrop/Services/LensProfileService.cs b/MagickCrop/Services/LensProfileService.cs
new file mode 100644
index 0000000..4b168c3
--- /dev/null
+++ b/MagickCrop/Services/LensProfileService.cs
@@ -0,0 +1,135 @@
+using MagickCrop.Models;
+using MagickCrop.Helpers;
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Collections.Generic;
+
+namespace MagickCrop.Services;
+
+public class LensProfileEntry
+{
+ public string? Key { get; set; }
+ public double A { get; set; }
+ public double B { get; set; }
+ public double C { get; set; }
+
+ [JsonIgnore]
+ public bool IsUserDefined { get; set; }
+
+ public override string ToString() => Key ?? string.Empty;
+}
+
+public static class LensProfileService
+{
+ private static List? _profiles;
+
+ private static string UserProfilesPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "MagickCrop",
+ "LensProfiles.user.json");
+
+ private static void EnsureLoaded()
+ {
+ if (_profiles is not null) return;
+
+ List profiles = [];
+
+ try
+ {
+ string baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? AppContext.BaseDirectory;
+ string jsonPath = Path.Combine(baseDir, "Resources", "LensProfiles.json");
+ if (File.Exists(jsonPath))
+ {
+ string json = File.ReadAllText(jsonPath);
+ profiles.AddRange(JsonSerializer.Deserialize>(json) ?? []);
+ }
+ }
+ catch (Exception)
+ {
+ // A missing or malformed built-in table should not block user profiles.
+ }
+
+ try
+ {
+ if (File.Exists(UserProfilesPath))
+ {
+ string json = File.ReadAllText(UserProfilesPath);
+ foreach (LensProfileEntry entry in JsonSerializer.Deserialize>(json) ?? [])
+ {
+ if (string.IsNullOrWhiteSpace(entry.Key)) continue;
+
+ // User entries win over a built-in profile with the same key.
+ profiles.RemoveAll(p => string.Equals(p.Key, entry.Key, StringComparison.OrdinalIgnoreCase));
+ entry.IsUserDefined = true;
+ profiles.Add(entry);
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Ignore corrupt user profile files.
+ }
+
+ _profiles = profiles;
+ }
+
+ public static IReadOnlyList GetProfiles()
+ {
+ EnsureLoaded();
+ return _profiles!;
+ }
+
+ public static LensProfileEntry? Save(string key, double a, double b, double c)
+ {
+ if (string.IsNullOrWhiteSpace(key)) return null;
+
+ EnsureLoaded();
+
+ key = key.Trim();
+ LensProfileEntry entry = new() { Key = key, A = a, B = b, C = c, IsUserDefined = true };
+
+ _profiles!.RemoveAll(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase));
+ _profiles.Add(entry);
+
+ try
+ {
+ string? directory = Path.GetDirectoryName(UserProfilesPath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ List userProfiles = [.. _profiles.Where(p => p.IsUserDefined)];
+ string json = JsonSerializer.Serialize(userProfiles, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(UserProfilesPath, json);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ return entry;
+ }
+
+ public static LensCorrectionSettings? Lookup(LensMetadata? meta)
+ {
+ if (meta is null) return null;
+ EnsureLoaded();
+
+ string combined = string.Join(" ", new[] { meta.CameraMake, meta.CameraModel, meta.LensMake, meta.LensModel }).Trim();
+ if (string.IsNullOrEmpty(combined)) return null;
+
+ foreach (var p in _profiles!)
+ {
+ if (string.IsNullOrEmpty(p.Key)) continue;
+ if (combined.IndexOf(p.Key, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return new LensCorrectionSettings { A = p.A, B = p.B, C = p.C };
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/MagickCrop/ViewModels/IMainWindowView.cs b/MagickCrop/ViewModels/IMainWindowView.cs
index 45763cc..37f051c 100644
--- a/MagickCrop/ViewModels/IMainWindowView.cs
+++ b/MagickCrop/ViewModels/IMainWindowView.cs
@@ -22,6 +22,10 @@ public interface IMainWindowView
bool IsLocalAdjustment { get; }
MagickGeometry GetLocalAdjustmentRegion();
+ // True when any measurement tool has been placed on the canvas.
+ // Geometric operations invalidate these, so callers can warn first.
+ bool HasMeasurements { get; }
+
// Busy state (delegates to existing SetUiForLongTask/SetUiForCompletedTask)
void SetBusy(bool busy);
diff --git a/MagickCrop/ViewModels/MainWindowViewModel.cs b/MagickCrop/ViewModels/MainWindowViewModel.cs
index 1bf78ce..4f16e5e 100644
--- a/MagickCrop/ViewModels/MainWindowViewModel.cs
+++ b/MagickCrop/ViewModels/MainWindowViewModel.cs
@@ -3,8 +3,10 @@
using ImageMagick;
using MagickCrop.Helpers;
using MagickCrop.Models;
+using MagickCrop.Services;
using MagickCrop.Models.MeasurementControls;
using MagickCrop.Windows;
+using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
@@ -55,6 +57,7 @@ public void SetView(IMainWindowView view)
[NotifyCanExecuteChangedFor(nameof(FlipVerticalCommand))]
[NotifyCanExecuteChangedFor(nameof(FlipHorizontalCommand))]
[NotifyCanExecuteChangedFor(nameof(ApplyThresholdCommand))]
+ // Lens-related commands do not need NotifyCanExecuteChanged entries here
private string? imagePath;
[ObservableProperty]
@@ -171,6 +174,17 @@ private void ShowAbout()
aboutWindow.ShowDialog();
}
+ [RelayCommand]
+ private void ShowLensCorrection()
+ {
+ if (_view is null) return;
+ var window = new MagickCrop.Windows.LensCorrectionWindow(this)
+ {
+ Owner = _view.OwnerWindow
+ };
+ window.ShowDialog();
+ }
+
// ββββββββββββββββββββββββββββββββββββββββββββββ
// Commands: Clipboard / Folder / Share
// ββββββββββββββββββββββββββββββββββββββββββββββ
@@ -281,10 +295,104 @@ private async Task Share()
[ObservableProperty]
private double thresholdValue = 128.0;
+ // Lens correction coefficients (barrel distortion)
+ [ObservableProperty]
+ private double lensCorrectionA = 0.0;
+
+ [ObservableProperty]
+ private double lensCorrectionB = 0.0;
+
+ [ObservableProperty]
+ private double lensCorrectionC = 0.0;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasDetectedLensDescription))]
+ private string detectedLensDescription = string.Empty;
+
+ public bool HasDetectedLensDescription => !string.IsNullOrEmpty(DetectedLensDescription);
+
+ public ObservableCollection LensProfiles { get; } = [];
+
+ [ObservableProperty]
+ private LensProfileEntry? selectedLensProfile;
+
+ // Guards against re-entrancy when a profile selection writes the coefficients.
+ private bool applyingLensProfile;
+
+ partial void OnSelectedLensProfileChanged(LensProfileEntry? value)
+ {
+ if (value is null || applyingLensProfile) return;
+
+ applyingLensProfile = true;
+ try
+ {
+ LensCorrectionA = value.A;
+ LensCorrectionB = value.B;
+ LensCorrectionC = value.C;
+ }
+ finally
+ {
+ applyingLensProfile = false;
+ }
+ }
+
+ public void LoadLensProfiles()
+ {
+ LensProfiles.Clear();
+ foreach (LensProfileEntry entry in LensProfileService.GetProfiles())
+ LensProfiles.Add(entry);
+ }
+
+ private void SelectProfileByKey(string? key)
+ {
+ if (string.IsNullOrWhiteSpace(key)) return;
+
+ LensProfileEntry? match = LensProfiles
+ .FirstOrDefault(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase));
+
+ if (match is null) return;
+
+ applyingLensProfile = true;
+ try
+ {
+ SelectedLensProfile = match;
+ }
+ finally
+ {
+ applyingLensProfile = false;
+ }
+ }
+
+ [RelayCommand]
+ private void SaveLensProfile()
+ {
+ string name = LensProfileName?.Trim() ?? string.Empty;
+ if (string.IsNullOrEmpty(name))
+ {
+ DetectedLensDescription = "Enter a name to save this profile";
+ return;
+ }
+
+ LensProfileEntry? saved = LensProfileService.Save(name, LensCorrectionA, LensCorrectionB, LensCorrectionC);
+ if (saved is null)
+ {
+ DetectedLensDescription = "Could not save the lens profile";
+ return;
+ }
+
+ LoadLensProfiles();
+ SelectProfileByKey(name);
+ LensProfileName = string.Empty;
+ DetectedLensDescription = $"Saved profile \u201c{name}\u201d";
+ }
+
+ [ObservableProperty]
+ private string lensProfileName = string.Empty;
+
[RelayCommand(CanExecute = nameof(CanApplyAdjustment))]
private Task ApplyThreshold() => ApplyAdjustmentAsync(img => img.Threshold(new Percentage(ThresholdValue / 255.0 * 100.0)));
- private async Task ApplyAdjustmentAsync(Action adjustment)
+ private async Task ApplyAdjustmentAsync(Action adjustment, bool forceFullImage = false)
{
if (_view is null || string.IsNullOrWhiteSpace(ImagePath))
return;
@@ -295,7 +403,7 @@ private async Task ApplyAdjustmentAsync(Action adjustment)
{
using MagickImage magickImage = new(ImagePath);
- if (_view.IsLocalAdjustment)
+ if (!forceFullImage && _view.IsLocalAdjustment)
{
MagickGeometry region = _view.GetLocalAdjustmentRegion();
@@ -331,8 +439,25 @@ await Task.Run(() =>
await Task.Run(() => adjustment(magickImage));
}
- string tempFileName = Path.GetTempFileName();
- await magickImage.WriteAsync(tempFileName);
+ // Path.GetTempFileName() hands back a ".tmp" name, and Magick picks its
+ // encoder from the extension - ".tmp" resolves to Unknown and fails to
+ // encode. Pick an explicit format, promoting to PNG when the operation
+ // introduced transparency that the source format cannot represent.
+ MagickFormat targetFormat = magickImage.Format;
+
+ if (targetFormat is MagickFormat.Unknown)
+ targetFormat = MagickFormat.Png;
+
+ if (magickImage.HasAlpha && targetFormat is MagickFormat.Jpeg or MagickFormat.Jpg or MagickFormat.Bmp)
+ targetFormat = MagickFormat.Png;
+
+ magickImage.Format = targetFormat;
+
+ string tempFileName = Path.ChangeExtension(
+ Path.GetTempFileName(),
+ targetFormat.ToString().ToLowerInvariant());
+
+ await magickImage.WriteAsync(tempFileName, targetFormat);
MagickImageUndoRedoItem undoRedoItem = new(_view.MainImageControl, ImagePath, tempFileName);
UndoRedo.AddUndo(undoRedoItem);
@@ -341,12 +466,118 @@ await Task.Run(() =>
_view.ImageSource = magickImage.ToBitmapSource();
ActualImageSize = new Size(magickImage.Width, magickImage.Height);
}
+ catch (Exception ex)
+ {
+ // Never let an image operation take down the app; surface it instead.
+ System.Windows.MessageBox.Show(
+ $"The image operation could not be completed.\n\n{ex.Message}",
+ "Image Operation Failed",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Warning);
+ }
finally
{
_view.SetBusy(false);
}
}
+ [RelayCommand(CanExecute = nameof(CanApplyAdjustment))]
+ private async Task ApplyLensCorrection()
+ {
+ if (_view is null)
+ return;
+
+ // Lens correction warps the image, so any calibrated scale and existing
+ // measurements no longer line up with the pixels underneath them.
+ if (_view.HasMeasurements)
+ {
+ Wpf.Ui.Controls.MessageBox confirm = new()
+ {
+ Title = "Lens Correction",
+ Content = "Lens correction changes the image geometry, so existing measurements and scale calibration will no longer be accurate.\n\nApply anyway?",
+ PrimaryButtonText = "Apply",
+ CloseButtonText = "Cancel",
+ };
+
+ if (await confirm.ShowDialogAsync() != Wpf.Ui.Controls.MessageBoxResult.Primary)
+ return;
+ }
+
+ double a = LensCorrectionA;
+ double b = LensCorrectionB;
+ double c = LensCorrectionC;
+ double d = 1.0 - a - b - c;
+
+ await ApplyAdjustmentAsync(img =>
+ {
+ img.VirtualPixelMethod = VirtualPixelMethod.Transparent;
+ // Barrel distortion takes four coefficients: A, B, C, D
+ img.Distort(DistortMethod.Barrel, a, b, c, d);
+ }, forceFullImage: true);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanApplyAdjustment))]
+ private void ResetLensCorrection()
+ {
+ applyingLensProfile = true;
+ try
+ {
+ SelectedLensProfile = null;
+ }
+ finally
+ {
+ applyingLensProfile = false;
+ }
+
+ LensCorrectionA = 0.0;
+ LensCorrectionB = 0.0;
+ LensCorrectionC = 0.0;
+ DetectedLensDescription = string.Empty;
+ }
+
+ [RelayCommand(CanExecute = nameof(CanApplyAdjustment))]
+ private void AutoDetectLensCorrection()
+ {
+ if (string.IsNullOrWhiteSpace(ImagePath)) return;
+
+ LensMetadata? meta = LensMetadataHelper.Read(ImagePath);
+ if (meta is null)
+ {
+ DetectedLensDescription = "No EXIF metadata found";
+ return;
+ }
+
+ string describe = string.Join(" ", new[] { meta.CameraMake, meta.CameraModel, meta.LensModel }
+ .Where(s => !string.IsNullOrWhiteSpace(s)));
+
+ LensCorrectionSettings? profile = LensProfileService.Lookup(meta);
+ if (profile is not null)
+ {
+ LensCorrectionA = profile.A;
+ LensCorrectionB = profile.B;
+ LensCorrectionC = profile.C;
+
+ // Reflect the EXIF match in the profile list so both paths agree.
+ string combined = string.Join(" ", new[] { meta.CameraMake, meta.CameraModel, meta.LensMake, meta.LensModel });
+ LensProfileEntry? matched = LensProfiles.FirstOrDefault(p =>
+ !string.IsNullOrEmpty(p.Key) && combined.Contains(p.Key, StringComparison.OrdinalIgnoreCase));
+ SelectProfileByKey(matched?.Key);
+
+ DetectedLensDescription = $"Detected: {describe}";
+ return;
+ }
+
+ DetectedLensDescription = string.IsNullOrWhiteSpace(describe)
+ ? "No matching lens profile found"
+ : $"{describe} β no profile, adjust manually";
+ }
+
+ [RelayCommand(CanExecute = nameof(CanApplyAdjustment))]
+ private Task ApplyExifOrientation()
+ {
+ return ApplyAdjustmentAsync(img => img.AutoOrient(), forceFullImage: true);
+ }
+
// ββββββββββββββββββββββββββββββββββββββββββββββ
// Commands: Rotate & Flip
// ββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/MagickCrop/Windows/LensCorrectionWindow.xaml b/MagickCrop/Windows/LensCorrectionWindow.xaml
new file mode 100644
index 0000000..8d71271
--- /dev/null
+++ b/MagickCrop/Windows/LensCorrectionWindow.xaml
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MagickCrop/Windows/LensCorrectionWindow.xaml.cs b/MagickCrop/Windows/LensCorrectionWindow.xaml.cs
new file mode 100644
index 0000000..cbddc49
--- /dev/null
+++ b/MagickCrop/Windows/LensCorrectionWindow.xaml.cs
@@ -0,0 +1,232 @@
+using ImageMagick;
+using MagickCrop.ViewModels;
+using System.ComponentModel;
+using System.IO;
+using System.Windows;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using Wpf.Ui.Controls;
+
+namespace MagickCrop.Windows;
+
+public partial class LensCorrectionWindow : FluentWindow
+{
+ private const uint ProxyLongestEdge = 1000;
+
+ private readonly MainWindowViewModel viewModel;
+ private readonly DispatcherTimer previewDebounce;
+ private IMainWindowView? mainView;
+ private MagickImage? proxyImage;
+ private BitmapSource? originalSource;
+ private int previewToken;
+ private bool applied;
+
+ public LensCorrectionWindow(MainWindowViewModel viewModel)
+ {
+ InitializeComponent();
+
+ this.viewModel = viewModel;
+ DataContext = viewModel;
+
+ previewDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
+ previewDebounce.Tick += PreviewDebounce_Tick;
+
+ Loaded += LensCorrectionWindow_Loaded;
+ Closed += LensCorrectionWindow_Closed;
+ }
+
+ private async void LensCorrectionWindow_Loaded(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ mainView = Owner as IMainWindowView ?? Application.Current.MainWindow as IMainWindowView;
+
+ if (mainView is null || string.IsNullOrWhiteSpace(viewModel.ImagePath) || !File.Exists(viewModel.ImagePath))
+ return;
+
+ originalSource = mainView.ImageSource;
+
+ string imagePath = viewModel.ImagePath;
+ proxyImage = await Task.Run(() => BuildProxy(imagePath));
+
+ viewModel.LoadLensProfiles();
+ viewModel.PropertyChanged += ViewModel_PropertyChanged;
+ viewModel.AutoDetectLensCorrectionCommand.Execute(null);
+
+ await RenderPreviewAsync();
+ }
+ catch (Exception)
+ {
+ // Fall back to a blank preview rather than crashing the dialog.
+ }
+ }
+
+ private static MagickImage? BuildProxy(string imagePath)
+ {
+ try
+ {
+ MagickImage image = new(imagePath);
+ uint longestEdge = Math.Max(image.Width, image.Height);
+
+ if (longestEdge > ProxyLongestEdge)
+ {
+ double scale = ProxyLongestEdge / (double)longestEdge;
+ image.Resize(new MagickGeometry((uint)(image.Width * scale), (uint)(image.Height * scale)));
+ }
+
+ return image;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(MainWindowViewModel.LensCorrectionA)
+ or nameof(MainWindowViewModel.LensCorrectionB)
+ or nameof(MainWindowViewModel.LensCorrectionC))
+ {
+ previewDebounce.Stop();
+ previewDebounce.Start();
+ }
+ }
+
+ private async void PreviewDebounce_Tick(object? sender, EventArgs e)
+ {
+ previewDebounce.Stop();
+
+ try
+ {
+ await RenderPreviewAsync();
+ }
+ catch (Exception)
+ {
+ // A failed preview frame should never crash the dialog.
+ }
+ }
+
+ private async Task RenderPreviewAsync()
+ {
+ if (mainView is null || proxyImage is null)
+ return;
+
+ int token = Interlocked.Increment(ref previewToken);
+
+ double a = viewModel.LensCorrectionA;
+ double b = viewModel.LensCorrectionB;
+ double c = viewModel.LensCorrectionC;
+ double d = 1.0 - a - b - c;
+
+ MagickImage source = proxyImage;
+
+ BitmapSource? rendered = await Task.Run(() =>
+ {
+ try
+ {
+ using MagickImage clone = new(source);
+ clone.VirtualPixelMethod = VirtualPixelMethod.Transparent;
+ clone.Distort(DistortMethod.Barrel, a, b, c, d);
+
+ BitmapSource bitmap = clone.ToBitmapSource();
+ bitmap.Freeze();
+ return bitmap;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ });
+
+ // Latest render wins; discard stale frames produced during a fast drag.
+ if (rendered is null || token != Volatile.Read(ref previewToken))
+ return;
+
+ mainView.ImageSource = rendered;
+ }
+
+ private async void ApplyButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ // Restore the real image first so the full-resolution operation and its
+ // undo entry are built from the unmodified source, not the proxy preview.
+ RestoreOriginalPreview();
+
+ applied = true;
+
+ // Get the dialog out of the way immediately; the full-resolution work
+ // reports progress through the main window's busy indicator. When
+ // measurements exist the command prompts for confirmation first, so
+ // stay visible until that has been answered.
+ if (mainView?.HasMeasurements != true)
+ Hide();
+
+ await viewModel.ApplyLensCorrectionCommand.ExecuteAsync(null);
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show(
+ $"Lens correction could not be applied.\n\n{ex.Message}",
+ "Lens Correction Failed",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Warning);
+ }
+
+ Close();
+ }
+
+ private async void AutoOrientButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ // Auto-orient rewrites the underlying image, so drop the preview first and
+ // rebuild the proxy from the newly oriented result afterwards.
+ RestoreOriginalPreview();
+ await viewModel.ApplyExifOrientationCommand.ExecuteAsync(null);
+
+ if (mainView is null || string.IsNullOrWhiteSpace(viewModel.ImagePath))
+ return;
+
+ originalSource = mainView.ImageSource;
+
+ proxyImage?.Dispose();
+ proxyImage = null;
+
+ string imagePath = viewModel.ImagePath;
+ proxyImage = await Task.Run(() => BuildProxy(imagePath));
+
+ await RenderPreviewAsync();
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show(
+ $"Auto-orient could not be applied.\n\n{ex.Message}",
+ "Auto-orient Failed",
+ System.Windows.MessageBoxButton.OK,
+ System.Windows.MessageBoxImage.Warning);
+ }
+ }
+
+ private void CloseButton_Click(object sender, RoutedEventArgs e) => Close();
+
+ private void LensCorrectionWindow_Closed(object? sender, EventArgs e)
+ {
+ previewDebounce.Stop();
+ previewDebounce.Tick -= PreviewDebounce_Tick;
+ viewModel.PropertyChanged -= ViewModel_PropertyChanged;
+
+ if (!applied)
+ RestoreOriginalPreview();
+
+ proxyImage?.Dispose();
+ proxyImage = null;
+ }
+
+ private void RestoreOriginalPreview()
+ {
+ if (mainView is not null && originalSource is not null)
+ mainView.ImageSource = originalSource;
+ }
+}