From 91f10de432eff0d9c17c380dd76689a04ea6c380 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 17 Jul 2026 17:20:39 -0500 Subject: [PATCH 1/9] Update NuGet package versions to latest releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated the following dependencies: - Magick.NET-Q16-AnyCPU: 14.14.0 → 14.15.0 - Magick.NET.SystemDrawing: 8.0.23 → 8.0.24 - Magick.NET.SystemWindowsMedia: 8.0.23 → 8.0.24 - Microsoft.WindowsAppSDK: 2.2.0 → 2.3.1 - System.Drawing.Common: 10.0.9 → 10.0.10 These updates bring in the latest bug fixes, features, and security improvements. No other code changes were made. --- MagickCrop/MagickCrop.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/MagickCrop/MagickCrop.csproj b/MagickCrop/MagickCrop.csproj index 111fdd9..9ce5224 100644 --- a/MagickCrop/MagickCrop.csproj +++ b/MagickCrop/MagickCrop.csproj @@ -43,11 +43,11 @@ - - - - - + + + + + From 299cf4ad43060f665f9297572fa4c3f632db1aa3 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 17 Jul 2026 17:51:29 -0500 Subject: [PATCH 2/9] Add annotated image save options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MagickCrop/Controls/SaveOptionsDialog.xaml | 20 ++- MagickCrop/Controls/SaveOptionsDialog.xaml.cs | 6 +- MagickCrop/MainWindow.xaml.cs | 130 +++++++++++++++++- MagickCrop/Models/SaveOptions.cs | 2 + 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/MagickCrop/Controls/SaveOptionsDialog.xaml b/MagickCrop/Controls/SaveOptionsDialog.xaml index 9b39611..a4f9dad 100644 --- a/MagickCrop/Controls/SaveOptionsDialog.xaml +++ b/MagickCrop/Controls/SaveOptionsDialog.xaml @@ -132,12 +132,20 @@ - + + + + + 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 = []; + 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; diff --git a/MagickCrop/Models/SaveOptions.cs b/MagickCrop/Models/SaveOptions.cs index 496dcef..d83c498 100644 --- a/MagickCrop/Models/SaveOptions.cs +++ b/MagickCrop/Models/SaveOptions.cs @@ -11,4 +11,6 @@ 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; } } From 5656faf4e22d04e68329219b8a5ed6eca9dfe310 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 17 Jul 2026 17:51:49 -0500 Subject: [PATCH 3/9] Gate annotation controls by tool tab Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Controls/AngleMeasurementControl.xaml.cs | 29 ++++- .../Controls/CircleMeasurementControl.xaml.cs | 26 ++++- .../DistanceMeasurementControl.xaml.cs | 24 +++++ .../Controls/MarkupShapeControl.xaml.cs | 11 ++ .../PolygonMeasurementControl.xaml.cs | 38 ++++++- .../RectangleMeasurementControl.xaml.cs | 26 ++++- MagickCrop/MainWindow.xaml.cs | 102 +++++++++++++++++- 7 files changed, 249 insertions(+), 7 deletions(-) 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/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/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/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs index 5646174..f1e238f 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -1709,8 +1709,10 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e) { ShapeType = activeMarkupShapeType, StrokeColor = markupColor, - StrokeThickness = markupSize + StrokeThickness = markupSize, + IsDragGizmoVisible = MarkupTabItem?.IsSelected == true }; + shapeControl.IsHitTestVisible = MarkupTabItem?.IsSelected == true; shapeControl.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; shapeControl.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; markupShapeControls.Add(shapeControl); @@ -1729,7 +1731,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); @@ -5180,6 +5183,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); @@ -5191,6 +5196,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; @@ -6535,9 +6541,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() @@ -6555,6 +6565,94 @@ 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) { if (MarkupToolsPanel is null || MarkupShapeToolsPanel is null) return; From 62b40c78105c785e3d03dfdc9ebf2a596987afbf Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Fri, 17 Jul 2026 18:42:10 -0500 Subject: [PATCH 4/9] Add live estimated file size to Save Options dialog Save Options dialog now displays a live "Estimated file size" that updates as users change format, quality, resize, markup, or measurement settings. Introduced a debounced, cancellable async estimation mechanism using a delegate for file size calculation. MainWindow provides an async estimation function that renders the image with overlays and computes output size without disk writes. Refactored image save logic into ApplySaveOptions and EncodeImageForSave helpers. Added SaveOptions.Clone() for thread safety. Improved resource cleanup and cancellation in the dialog. UI updated to accommodate the new estimated size display. --- MagickCrop/Controls/SaveOptionsDialog.xaml | 15 ++- MagickCrop/Controls/SaveOptionsDialog.xaml.cs | 119 +++++++++++++++++- MagickCrop/MainWindow.xaml.cs | 90 ++++++++++--- MagickCrop/Models/SaveOptions.cs | 13 ++ 4 files changed, 217 insertions(+), 20 deletions(-) diff --git a/MagickCrop/Controls/SaveOptionsDialog.xaml b/MagickCrop/Controls/SaveOptionsDialog.xaml index a4f9dad..5d52f56 100644 --- a/MagickCrop/Controls/SaveOptionsDialog.xaml +++ b/MagickCrop/Controls/SaveOptionsDialog.xaml @@ -18,6 +18,7 @@ + @@ -141,15 +142,25 @@ + Checked="IncludeMarkupCheckBox_CheckedChanged" + Content="Include markup" + Unchecked="IncludeMarkupCheckBox_CheckedChanged" /> + + diff --git a/MagickCrop/Controls/SaveOptionsDialog.xaml.cs b/MagickCrop/Controls/SaveOptionsDialog.xaml.cs index 04fc325..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,6 +64,7 @@ public SaveOptionsDialog(double imageWidth, double imageHeight) { Format = MagickFormat.Png, Extension = ".png", + Quality = (int)QualitySlider.Value, Resize = false, Width = (int)originalWidth, Height = (int)originalHeight, @@ -55,6 +74,14 @@ public SaveOptionsDialog(double imageWidth, double imageHeight) }; } + 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; @@ -66,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(); } } @@ -76,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) @@ -89,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) @@ -107,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) @@ -125,6 +157,89 @@ 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) @@ -133,6 +248,7 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e) 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) { @@ -152,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/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs index f1e238f..cb2a4d0 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -1027,7 +1027,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", @@ -1070,19 +1077,7 @@ private async void Save_Click(object sender, RoutedEventArgs e) string correctedImageFileName = saveFileDialog.FileName; using MagickImage image = CreateImageForSave(options, (int)width, (int)height); - - // 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; + ApplySaveOptions(image, options); // Save with the selected format await image.WriteAsync(correctedImageFileName, options.Format); @@ -1120,12 +1115,73 @@ private MagickImage CreateImageForSave(SaveOptions options, int imageWidth, int 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(renderedImage)); + encoder.Frames.Add(BitmapFrame.Create(image)); using MemoryStream stream = new(); encoder.Save(stream); - stream.Position = 0; - return new MagickImage(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( diff --git a/MagickCrop/Models/SaveOptions.cs b/MagickCrop/Models/SaveOptions.cs index d83c498..26fbbfb 100644 --- a/MagickCrop/Models/SaveOptions.cs +++ b/MagickCrop/Models/SaveOptions.cs @@ -13,4 +13,17 @@ public class SaveOptions 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 + }; } From f8f32970904c298a09c60c8f6475cb4f0aacafae Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 20:08:45 -0500 Subject: [PATCH 5/9] Add floating nav bar, collapsible sidebar, and group select - Added floating canvas navigation bar (zoom, pan, fit, bounds) and collapsible tools sidebar to MainWindow UI; both are toggleable - Refactored zoom/pan logic with new methods for zooming at a point, fitting bounds, and syncing controls - Improved transform handle dragging with optional image bounds constraint - Added rubber-band marquee selection for markup "Select" tool; supports multi-item selection and group moves with undo/redo - Visual highlights for selected markup text controls - Extended Undo/Redo with MarkupGroupMovedItem for group moves - Improved Delete key handling to remove all selected markup as a group - Refactored coordinate conversion and handle scaling - Updated event handlers and UI logic for new features --- .../Controls/PixelPrecisionZoom.xaml.cs | 69 +- MagickCrop/MainWindow.xaml | 219 ++++- MagickCrop/MainWindow.xaml.cs | 841 +++++++++++++++--- MagickCrop/Models/DraggingMode.cs | 4 +- MagickCrop/Models/UndoRedo.cs | 65 +- 5 files changed, 1036 insertions(+), 162 deletions(-) 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/MainWindow.xaml b/MagickCrop/MainWindow.xaml index 2dbb5c3..a1b0259 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -46,6 +46,36 @@ + + @@ -55,13 +85,14 @@ - + + @@ -87,7 +118,18 @@ Background="Gray" ClipToBounds="True" IsManipulationEnabled="True" + PreviewMouseDown="MainGrid_PreviewMouseDown" + PreviewMouseUp="MainGrid_PreviewMouseUp" PreviewMouseWheel="ShapeCanvas_PreviewMouseWheel"> + + + + + @@ -253,6 +295,17 @@ + + + @@ -278,6 +331,134 @@ Text="0°" Visibility="Collapsed" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -337,7 +544,7 @@ - + @@ -2176,6 +2383,6 @@ + Grid.ColumnSpan="3" /> diff --git a/MagickCrop/MainWindow.xaml.cs b/MagickCrop/MainWindow.xaml.cs index cb2a4d0..a3f8e9a 100644 --- a/MagickCrop/MainWindow.xaml.cs +++ b/MagickCrop/MainWindow.xaml.cs @@ -58,8 +58,12 @@ 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 const double DefaultSidebarWidth = 240; // Size input properties private bool isUpdatingFromCode = false; @@ -116,6 +120,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 +231,7 @@ public MainWindow() ApplicationAccentColorManager.Apply(teal); InitializeComponent(); + canvasScale.Changed += CanvasScale_Changed; // 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 +284,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 +312,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 +358,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 +376,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 +543,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 +558,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 +589,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 +695,14 @@ 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); if (draggingMode == DraggingMode.CreatingMeasurement && isCreatingMeasurement) { @@ -719,6 +795,46 @@ 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; + } + private void MovePolyline(Point newPoint) { if (pointDraggingIndex < 0) @@ -1394,7 +1510,7 @@ private async void PasteButton_Click(object sender, RoutedEventArgs e) await OpenImagePath(tempFileName); // Update UI - BottomBorder.Visibility = Visibility.Visible; + ShowSidebar(); } catch (Exception ex) { @@ -1433,7 +1549,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 { @@ -1459,7 +1575,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)); @@ -1539,7 +1655,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 @@ -1590,29 +1706,8 @@ private void ShapeCanvas_PreviewMouseWheel(object sender, MouseWheelEventArgs e) // 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; } @@ -1710,16 +1805,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) { @@ -1766,9 +1851,9 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e) ShapeType = activeMarkupShapeType, StrokeColor = markupColor, StrokeThickness = markupSize, - IsDragGizmoVisible = MarkupTabItem?.IsSelected == true + IsDragGizmoVisible = MarkupTabItem?.IsSelected == true, + IsHitTestVisible = MarkupTabItem?.IsSelected == true }; - shapeControl.IsHitTestVisible = MarkupTabItem?.IsSelected == true; shapeControl.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; shapeControl.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; markupShapeControls.Add(shapeControl); @@ -2194,6 +2279,16 @@ private async void FluentWindow_PreviewDrop(object sender, DragEventArgs e) } private void ResetMenuItem_Click(object sender, RoutedEventArgs e) + { + ResetCanvasNavigation(); + } + + private void ResetCanvasNavigationButton_Click(object sender, RoutedEventArgs e) + { + ResetCanvasNavigation(); + } + + private void ResetCanvasNavigation() { canvasScale.ScaleX = 1; canvasScale.ScaleY = 1; @@ -2203,6 +2298,114 @@ private void ResetMenuItem_Click(object sender, RoutedEventArgs e) 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(); + } + + 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; + 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(); } /// @@ -2227,48 +2430,214 @@ private void CenterAndZoomToFit() if (imageWidth == 0 || imageHeight == 0) return; - // 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; + 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 (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); - // Calculate scale factors to fit the image in the viewport - double scaleX = availableWidth / imageWidth; - double scaleY = availableHeight / imageHeight; + if (e.OriginalSource is DependencyObject originalSource) + { + MarkupShapeControl? hitShape = FindAncestor(originalSource); + MarkupTextControl? hitText = FindAncestor(originalSource); - // Use the smaller scale to ensure the entire image fits - double scale = Math.Min(scaleX, scaleY); + 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; - // Clamp scale to min/max zoom limits - scale = Math.Clamp(scale, MinZoom, MaxZoom); + if (alreadySelected && totalSelected > 1) + { + BeginMarkupGroupMove(clickPoint); + e.Handled = true; + return true; + } - // Apply the scale - canvasScale.ScaleX = scale; - canvasScale.ScaleY = scale; + // 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; + } + } - // Calculate the scaled image dimensions - double scaledImageWidth = imageWidth * scale; - double scaledImageHeight = imageHeight * scale; + Stroke? hitStroke = null; + foreach (Stroke stroke in MarkupCanvas.Strokes) + { + if (stroke.HitTest(clickPoint)) + { + hitStroke = stroke; + break; + } + } - // 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; + 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 + } - // Center the scaled image in the viewport - double translateX = (viewportWidth - scaledImageWidth) / 2 - (canvasMarginX * scale); - double translateY = (viewportHeight - scaledImageHeight) / 2 - (canvasMarginY * scale); + BeginMarkupMarquee(clickPoint); + e.Handled = true; + return true; + } - canvasTranslate.X = translateX; - canvasTranslate.Y = translateY; + 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; } - /// - /// Menu item handler to center and zoom to fit the image on demand. - /// - private void CenterAndZoomToFitMenuItem_Click(object sender, RoutedEventArgs e) + private void CanvasContextMenu_Opened(object sender, RoutedEventArgs e) { - CenterAndZoomToFit(); + bool isBarVisible = CanvasNavigationBar.Visibility == Visibility.Visible; + + ToggleCanvasNavigationMenuItem.IsEnabled = ViewModel.HasImage; + ToggleCanvasNavigationMenuItem.IsChecked = isBarVisible; + + ToggleCanvasNavigationBarMenuItem.IsEnabled = ViewModel.HasImage; + ToggleCanvasNavigationBarMenuItem.IsChecked = isBarVisible; + } + + private void ToggleCanvasNavigationMenuItem_Click(object sender, RoutedEventArgs e) + { + bool showBar = sender is System.Windows.Controls.MenuItem { IsCheckable: true } menuItem + ? menuItem.IsChecked + : CanvasNavigationBar.Visibility != Visibility.Visible; + + if (showBar) + CanvasNavigationBar.ClearValue(UIElement.VisibilityProperty); + else + CanvasNavigationBar.Visibility = Visibility.Collapsed; + + // Keep both menu items (canvas background + canvas bar) in sync. + ToggleCanvasNavigationMenuItem.IsChecked = showBar; + ToggleCanvasNavigationBarMenuItem.IsChecked = showBar; + } + + 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"; + } + + private void HideSidebar() + { + BottomBorder.Visibility = Visibility.Collapsed; + SidebarColumn.Width = new GridLength(0); + SidebarToggleButton.IsChecked = false; + SidebarToggleButton.Visibility = Visibility.Collapsed; + } + + private void SidebarToggleButton_Checked(object sender, RoutedEventArgs e) + { + if (!IsInitialized) + return; + + BottomBorder.Visibility = Visibility.Visible; + SidebarColumn.Width = new GridLength(DefaultSidebarWidth); + SidebarToggleText.Text = "Hide tools"; + SidebarToggleButton.ToolTip = "Collapse tools sidebar"; + } + + private void SidebarToggleButton_Unchecked(object sender, RoutedEventArgs e) + { + if (!IsInitialized) + return; + + BottomBorder.Visibility = Visibility.Collapsed; + SidebarColumn.Width = new GridLength(0); + SidebarToggleText.Text = "Show tools"; + SidebarToggleButton.ToolTip = "Show tools sidebar"; + } + + private void MainGrid_PreviewMouseUp(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Middle || draggingMode != DraggingMode.Panning) + return; + + draggingMode = DraggingMode.None; + MainGrid.ReleaseMouseCapture(); + Cursor = null; + e.Handled = true; + } + + private void ZoomAtCanvasPoint(double scale, Point canvasPoint) + { + double originalScale = canvasScale.ScaleX; + if (originalScale <= 0) + return; + + 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(); + } + + private void ZoomAtViewportPoint(double scale, Point viewportPoint) + { + 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) @@ -4506,11 +4875,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 @@ -5532,7 +5900,7 @@ private void ResetApplicationState() HideResizeControls(); HideObjectEraseControls(); HideThresholdControls(); - BottomBorder.Visibility = Visibility.Collapsed; + HideSidebar(); WelcomeMessageModal.Visibility = Visibility.Visible; OpenFolderButton.IsEnabled = false; Save.IsEnabled = false; @@ -5940,13 +6308,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; } @@ -6494,46 +6881,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)); } /// @@ -6711,6 +7070,9 @@ private static void SetMeasurementGizmoState(T control, bool visible) 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; @@ -6806,6 +7168,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; @@ -6981,7 +7554,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/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; } From c1bc1e2b190702a1c488ddced94eee315b86aa1a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 20:18:02 -0500 Subject: [PATCH 6/9] Add mini map overlay for canvas navigation Introduced a MiniMap control showing a thumbnail with viewport rectangle. Integrated into MainWindow with toggleable visibility via menu/context menu. Updated MainWindow logic to sync mini map with canvas zoom/pan/resize and handle user panning via the mini map. Ensured mini map events do not interfere with main canvas interaction. UI state management updated to support mini map controls. --- MagickCrop/Controls/MiniMap.xaml | 48 +++++++++++++ MagickCrop/Controls/MiniMap.xaml.cs | 108 ++++++++++++++++++++++++++++ MagickCrop/MainWindow.xaml | 19 +++++ MagickCrop/MainWindow.xaml.cs | 89 ++++++++++++++++++++++- 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 MagickCrop/Controls/MiniMap.xaml create mode 100644 MagickCrop/Controls/MiniMap.xaml.cs diff --git a/MagickCrop/Controls/MiniMap.xaml b/MagickCrop/Controls/MiniMap.xaml new file mode 100644 index 0000000..3e762ee --- /dev/null +++ b/MagickCrop/Controls/MiniMap.xaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/MiniMap.xaml.cs b/MagickCrop/Controls/MiniMap.xaml.cs new file mode 100644 index 0000000..b8f0343 --- /dev/null +++ b/MagickCrop/Controls/MiniMap.xaml.cs @@ -0,0 +1,108 @@ +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; + + RequestCenter(e.GetPosition(MapHost)); + e.Handled = true; + } + + private void MapHost_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (!isDragging) + return; + + isDragging = false; + MapHost.ReleaseMouseCapture(); + e.Handled = true; + } +} diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index a1b0259..ff04dc3 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -128,6 +128,11 @@ Click="ToggleCanvasNavigationMenuItem_Click" Header="Show canvas controls" IsCheckable="True" /> + @@ -351,6 +356,11 @@ Click="ToggleCanvasNavigationMenuItem_Click" Header="Show canvas controls" IsCheckable="True" /> + @@ -459,6 +469,15 @@ + + + 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; @@ -1700,7 +1710,7 @@ 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 @@ -2339,6 +2349,59 @@ private void CanvasScale_Changed(object? sender, EventArgs e) 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) + { + double scale = canvasScale.ScaleX; + if (scale <= 0) + return; + + canvasTranslate.X = (MainGrid.ActualWidth / 2) - CanvasOriginOffset - (canvasPoint.X * scale); + canvasTranslate.Y = (MainGrid.ActualHeight / 2) - CanvasOriginOffset - (canvasPoint.Y * scale); } private void UpdateCanvasNavigationUi() @@ -2443,6 +2506,9 @@ private void CenterAndZoomToFitMenuItem_Click(object sender, RoutedEventArgs e) private void MainGrid_PreviewMouseDown(object sender, MouseButtonEventArgs e) { + if (IsOverMiniMap(e)) + return; + if (e.ChangedButton == MouseButton.Left && isMarkupSelectMode && TryStartMarkupSelectGesture(e)) { @@ -2523,6 +2589,9 @@ private bool TryStartMarkupSelectGesture(MouseButtonEventArgs e) return true; } + private static bool IsOverMiniMap(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) @@ -2543,6 +2612,24 @@ private void CanvasContextMenu_Opened(object sender, RoutedEventArgs e) ToggleCanvasNavigationBarMenuItem.IsEnabled = ViewModel.HasImage; ToggleCanvasNavigationBarMenuItem.IsChecked = isBarVisible; + + ToggleMiniMapMenuItem.IsEnabled = ViewModel.HasImage; + ToggleMiniMapMenuItem.IsChecked = showMiniMap; + + ToggleMiniMapBarMenuItem.IsEnabled = ViewModel.HasImage; + ToggleMiniMapBarMenuItem.IsChecked = showMiniMap; + } + + private void ToggleMiniMapMenuItem_Click(object sender, RoutedEventArgs e) + { + showMiniMap = sender is System.Windows.Controls.MenuItem { IsCheckable: true } menuItem + ? menuItem.IsChecked + : !showMiniMap; + + ToggleMiniMapMenuItem.IsChecked = showMiniMap; + ToggleMiniMapBarMenuItem.IsChecked = showMiniMap; + + UpdateMiniMap(); } private void ToggleCanvasNavigationMenuItem_Click(object sender, RoutedEventArgs e) From ae6516ae174ac4693787c15d0394d19e1575d0a4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 1 Aug 2026 21:43:52 -0500 Subject: [PATCH 7/9] Add CornerNavButton for fast handle navigation in polygons Introduced CornerNavButton control for quick viewport centering on polygon handles. Styled button and integrated dynamic creation, positioning, and scaling logic. Updated canvas and pan/zoom handling for smooth navigation and animation cancellation. Improved hit-testing and minimap drag robustness. --- MagickCrop/Controls/CornerNavButton.cs | 52 +++++ MagickCrop/Controls/MiniMap.xaml | 1 + MagickCrop/Controls/MiniMap.xaml.cs | 22 ++- MagickCrop/MainWindow.CornerNavigation.cs | 226 ++++++++++++++++++++++ MagickCrop/MainWindow.xaml | 33 ++++ MagickCrop/MainWindow.xaml.cs | 42 +++- 6 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 MagickCrop/Controls/CornerNavButton.cs create mode 100644 MagickCrop/MainWindow.CornerNavigation.cs 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/MiniMap.xaml b/MagickCrop/Controls/MiniMap.xaml index 3e762ee..dc852e0 100644 --- a/MagickCrop/Controls/MiniMap.xaml +++ b/MagickCrop/Controls/MiniMap.xaml @@ -25,6 +25,7 @@ Height="120" ClipToBounds="True" Cursor="SizeAll" + LostMouseCapture="MapHost_LostMouseCapture" MouseLeftButtonDown="MapHost_MouseLeftButtonDown" MouseLeftButtonUp="MapHost_MouseLeftButtonUp" MouseMove="MapHost_MouseMove" diff --git a/MagickCrop/Controls/MiniMap.xaml.cs b/MagickCrop/Controls/MiniMap.xaml.cs index b8f0343..c0f89ce 100644 --- a/MagickCrop/Controls/MiniMap.xaml.cs +++ b/MagickCrop/Controls/MiniMap.xaml.cs @@ -92,6 +92,12 @@ 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; } @@ -101,8 +107,20 @@ private void MapHost_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) if (!isDragging) return; - isDragging = false; - MapHost.ReleaseMouseCapture(); + 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/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 ff04dc3..5092539 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -37,6 +37,39 @@ + +