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"> + + + + + + @@ -253,6 +333,17 @@ + + + @@ -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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +