diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bd1cd19..0936a2d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,8 +14,11 @@ "Bash(find:*)", "Bash(ls:*)", "Bash(/mnt/c/Program\\ Files/dotnet/dotnet build)", - "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)" + "Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)", + "PowerShell(git diff *)", + "PowerShell(dotnet build *)", + "Bash(gh issue list *)" ], "deny": [] } -} \ No newline at end of file +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccbdbe4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Magick Crop & Measure is a WPF desktop app (Windows 10+) that corrects perspective distortion in photos and provides measurement tools. It uses ImageMagick for image processing, Emgu.CV (OpenCV wrapper) for shape detection, and WPF-UI (Fluent Design) for the UI. + +## Build Commands + +```bash +# Build the solution (from repo root) +dotnet build MagickCrop.sln + +# Build a specific platform (arm64, x64, x86) +dotnet build MagickCrop/MagickCrop.csproj -p:Platform=x64 + +# Run (requires Windows with .NET 10) +dotnet run --project MagickCrop/MagickCrop.csproj + +# Run tests +dotnet test MagickCrop.Tests/ + +# Publish self-contained for x64 +dotnet publish MagickCrop/MagickCrop.csproj -r win-x64 --self-contained -o bld/x64/MagickCrop-Self-Contained +``` + +The MSIX packaging project (`MagickCrop-Package/MagickCrop-Package.wapproj`) is used for Store submissions and requires Visual Studio. + +## Architecture + +### Key Namespaces + +- `MagickCrop` — root namespace; `MainWindow.xaml.cs` and `MainWindow.QuadrilateralHover.cs` are partial classes for the main window +- `MagickCrop.ViewModels` — `MainWindowViewModel` (CommunityToolkit.Mvvm `ObservableObject`) + `IMainWindowView` interface +- `MagickCrop.Helpers` — stateless static helpers for image processing operations +- `MagickCrop.Controls` — WPF `UserControl` subclasses for measurement overlays and interactive controls +- `MagickCrop.Models` — DTOs, `UndoRedo` stack, `AspectRatio`, `DraggingMode` enum +- `MagickCrop.Models.MeasurementControls` — serializable DTOs for each measurement type +- `MagickCrop.Services` — `RecentProjectsManager` (JSON-backed project persistence in `%LocalAppData%\MagickCrop`) +- `MagickCrop.Behaviors` — `PinchZoomBehavior` attached property + +### View / ViewModel Split + +`MainWindow` implements `IMainWindowView`, which exposes just the properties the ViewModel needs (image source, busy state, local-adjustment region). `MainWindowViewModel` holds all commands (CommunityToolkit source-generated `[RelayCommand]`) and observable state. UI-only logic (dragging handles, polygon rendering) stays in the code-behind. + +### Image Processing Pipeline + +All image mutations write to a **temp file** on disk and reload from it. Undo/redo (`UndoRedo` + `MagickImageUndoRedoItem`) stores before/after file paths and reloads from disk on undo/redo — there is no in-memory image stack. + +Key helpers: +- `QuadrilateralDetector` — Emgu.CV-based contour detection, returns `DetectedQuadrilateral` with confidence score +- `GridStraightenHelper` — polynomial distortion via ImageMagick for grid-based warp correction +- `UnWarpCorrector` — barrel/pincushion correction using transfinite interpolation + polynomial distortion +- `EdgeCorrectionHelper` — wavy-edge straightening using the same transfinite interpolation approach +- `ImageExtensions` / `MagickExtensions` — extension methods bridging WPF types and Magick.NET types + +### Measurement Controls + +Each measurement type (Distance, Angle, Rectangle, Polygon, Circle, VerticalLine, HorizontalLine) is a `UserControl` with a corresponding `*Dto` model for JSON serialization. Controls live on a WPF `Canvas` overlaid on the image. + +### Project Persistence + +`RecentProjectsManager` saves projects as JSON files (including measurement state as `MagickCropMeasurementPackage`) with thumbnails under `%LocalAppData%\MagickCrop\Projects`. An auto-save timer fires every 5 seconds when a project is open. + +### UI Framework + +Uses **WPF-UI** (`Wpf.Ui`) for `FluentWindow`, theming, and Fluent controls. `MainWindow` extends `FluentWindow`. Theme follows system (light/dark) via `ApplicationThemeManager`. diff --git a/MagickCrop-Package/MagickCrop-Package.wapproj b/MagickCrop-Package/MagickCrop-Package.wapproj index 86d4529..60866bb 100644 --- a/MagickCrop-Package/MagickCrop-Package.wapproj +++ b/MagickCrop-Package/MagickCrop-Package.wapproj @@ -165,7 +165,7 @@ - + diff --git a/MagickCrop-Package/Package.appxmanifest b/MagickCrop-Package/Package.appxmanifest index 6201c8a..2083f04 100644 --- a/MagickCrop-Package/Package.appxmanifest +++ b/MagickCrop-Package/Package.appxmanifest @@ -1,4 +1,4 @@ - + + Version="1.12.0.0" /> MagickCrop diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml b/MagickCrop/Controls/MarkupShapeControl.xaml new file mode 100644 index 0000000..28bd83a --- /dev/null +++ b/MagickCrop/Controls/MarkupShapeControl.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/MarkupShapeControl.xaml.cs b/MagickCrop/Controls/MarkupShapeControl.xaml.cs new file mode 100644 index 0000000..ca71d83 --- /dev/null +++ b/MagickCrop/Controls/MarkupShapeControl.xaml.cs @@ -0,0 +1,215 @@ +using MagickCrop.Models; +using MagickCrop.Models.MeasurementControls; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace MagickCrop.Controls; + +public partial class MarkupShapeControl : UserControl +{ + private Point point1 = new(100, 100); + private Point point2 = new(300, 300); + private int pointDraggingIndex = -1; + + public event MouseButtonEventHandler? MeasurementPointMouseDown; + + public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e); + public event RemoveControlRequestedEventHandler? RemoveControlRequested; + + private MarkupShapeType shapeType = MarkupShapeType.Rectangle; + public MarkupShapeType ShapeType + { + get => shapeType; + set + { + shapeType = value; + UpdateShapeVisibility(); + UpdatePositions(); + } + } + + private Color strokeColor = Colors.Red; + public Color StrokeColor + { + get => strokeColor; + set + { + strokeColor = value; + UpdateColors(); + } + } + + private double strokeThickness = 3.0; + public double StrokeThickness + { + get => strokeThickness; + set + { + strokeThickness = value; + UpdateThickness(); + UpdatePositions(); + } + } + + public MarkupShapeControl() + { + InitializeComponent(); + UpdateShapeVisibility(); + UpdatePositions(); + UpdateColors(); + } + + private void UpdateShapeVisibility() + { + ShapeLine.Visibility = shapeType is MarkupShapeType.Line or MarkupShapeType.Arrow + ? Visibility.Visible : Visibility.Collapsed; + ArrowHead.Visibility = shapeType == MarkupShapeType.Arrow + ? Visibility.Visible : Visibility.Collapsed; + ShapeRectangle.Visibility = shapeType == MarkupShapeType.Rectangle + ? Visibility.Visible : Visibility.Collapsed; + ShapeEllipse.Visibility = shapeType == MarkupShapeType.Ellipse + ? Visibility.Visible : Visibility.Collapsed; + } + + private void UpdateColors() + { + SolidColorBrush brush = new(strokeColor); + ShapeLine.Stroke = brush; + ArrowHead.Fill = new SolidColorBrush(strokeColor); + ShapeRectangle.Stroke = new SolidColorBrush(strokeColor); + ShapeEllipse.Stroke = new SolidColorBrush(strokeColor); + Point1Handle.Fill = new SolidColorBrush(strokeColor); + Point2Handle.Fill = new SolidColorBrush(strokeColor); + } + + private void UpdateThickness() + { + ShapeLine.StrokeThickness = strokeThickness; + ShapeRectangle.StrokeThickness = strokeThickness; + ShapeEllipse.StrokeThickness = strokeThickness; + } + + private void UpdatePositions() + { + Canvas.SetLeft(Point1Handle, point1.X - Point1Handle.Width / 2); + Canvas.SetTop(Point1Handle, point1.Y - Point1Handle.Height / 2); + Canvas.SetLeft(Point2Handle, point2.X - Point2Handle.Width / 2); + Canvas.SetTop(Point2Handle, point2.Y - Point2Handle.Height / 2); + + switch (shapeType) + { + case MarkupShapeType.Line: + case MarkupShapeType.Arrow: + ShapeLine.X1 = point1.X; + ShapeLine.Y1 = point1.Y; + ShapeLine.X2 = point2.X; + ShapeLine.Y2 = point2.Y; + if (shapeType == MarkupShapeType.Arrow) + UpdateArrowHead(); + break; + + case MarkupShapeType.Rectangle: + double rx = Math.Min(point1.X, point2.X); + double ry = Math.Min(point1.Y, point2.Y); + Canvas.SetLeft(ShapeRectangle, rx); + Canvas.SetTop(ShapeRectangle, ry); + ShapeRectangle.Width = Math.Max(1, Math.Abs(point2.X - point1.X)); + ShapeRectangle.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y)); + break; + + case MarkupShapeType.Ellipse: + double ex = Math.Min(point1.X, point2.X); + double ey = Math.Min(point1.Y, point2.Y); + Canvas.SetLeft(ShapeEllipse, ex); + Canvas.SetTop(ShapeEllipse, ey); + ShapeEllipse.Width = Math.Max(1, Math.Abs(point2.X - point1.X)); + ShapeEllipse.Height = Math.Max(1, Math.Abs(point2.Y - point1.Y)); + break; + } + } + + private void UpdateArrowHead() + { + double dx = point2.X - point1.X; + double dy = point2.Y - point1.Y; + double len = Math.Sqrt(dx * dx + dy * dy); + if (len < 1) return; + + double arrowSize = Math.Max(14, strokeThickness * 4); + double angle = Math.Atan2(dy, dx); + + Point tip = point2; + Point b1 = new( + tip.X - arrowSize * Math.Cos(angle - Math.PI / 6), + tip.Y - arrowSize * Math.Sin(angle - Math.PI / 6)); + Point b2 = new( + tip.X - arrowSize * Math.Cos(angle + Math.PI / 6), + tip.Y - arrowSize * Math.Sin(angle + Math.PI / 6)); + + ArrowHead.Points = [tip, b1, b2]; + } + + private void HandlePoint_MouseDown(object sender, MouseButtonEventArgs e) + { + if (sender is not Ellipse ellipse || ellipse.Tag is not string s) + return; + pointDraggingIndex = int.Parse(s); + MeasurementPointMouseDown?.Invoke(sender, e); + } + + public void MovePoint(int pointIndex, Point newPosition) + { + if (pointIndex == 0) point1 = newPosition; + else if (pointIndex == 1) point2 = newPosition; + UpdatePositions(); + } + + public void StartDraggingPoint(int pointIndex) + { + pointDraggingIndex = pointIndex; + MeasurementPointMouseDown?.Invoke( + pointIndex == 0 ? Point1Handle : Point2Handle, null!); + } + + public int GetActivePointIndex() => pointDraggingIndex; + + public void ResetActivePoint() => pointDraggingIndex = -1; + + public (Point Point1, Point Point2) GetPoints() => (point1, point2); + + private void RemoveMenuItem_Click(object sender, RoutedEventArgs e) + { + RemoveControlRequested?.Invoke(this, EventArgs.Empty); + } + + public MarkupShapeDto ToDto() + { + return new MarkupShapeDto + { + ShapeType = shapeType, + Point1 = point1, + Point2 = point2, + StrokeColor = strokeColor.ToString(), + StrokeThickness = strokeThickness + }; + } + + public void FromDto(MarkupShapeDto dto) + { + shapeType = dto.ShapeType; + point1 = dto.Point1; + point2 = dto.Point2; + + try { strokeColor = (Color)ColorConverter.ConvertFromString(dto.StrokeColor); } + catch { strokeColor = Colors.Red; } + + strokeThickness = dto.StrokeThickness; + UpdateShapeVisibility(); + UpdateColors(); + UpdateThickness(); + UpdatePositions(); + } +} diff --git a/MagickCrop/Controls/MarkupTextControl.xaml b/MagickCrop/Controls/MarkupTextControl.xaml new file mode 100644 index 0000000..c6d063d --- /dev/null +++ b/MagickCrop/Controls/MarkupTextControl.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + diff --git a/MagickCrop/Controls/MarkupTextControl.xaml.cs b/MagickCrop/Controls/MarkupTextControl.xaml.cs new file mode 100644 index 0000000..99e0fdf --- /dev/null +++ b/MagickCrop/Controls/MarkupTextControl.xaml.cs @@ -0,0 +1,220 @@ +using MagickCrop.Models.MeasurementControls; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; + +namespace MagickCrop.Controls; + +public partial class MarkupTextControl : UserControl +{ + private Point dragOffset; + private Point positionBeforeDrag; + private bool isDragging; + private string textBeforeEdit = "Text"; + + public delegate void RemoveControlRequestedEventHandler(object sender, EventArgs e); + public event RemoveControlRequestedEventHandler? RemoveControlRequested; + + public delegate void TextMovedEventHandler(object sender, Point before, Point after); + public event TextMovedEventHandler? TextMoved; + + public event EventHandler? EditCommitted; + public event EventHandler? EditCancelled; + + public bool IsEditing => EditBox.Visibility == Visibility.Visible; + + /// + /// The text as it was when the current or most recent edit began. + /// + public string TextBeforeEdit => textBeforeEdit; + + private Color textColor = Colors.Red; + public Color TextColor + { + get => textColor; + set + { + textColor = value; + SolidColorBrush brush = new(textColor); + DisplayText.Foreground = brush; + EditBox.Foreground = brush; + EditBox.CaretBrush = brush; + } + } + + private double markupFontSize = 16.0; + public double MarkupFontSize + { + get => markupFontSize; + set + { + markupFontSize = value; + DisplayText.FontSize = markupFontSize; + EditBox.FontSize = markupFontSize; + } + } + + public string MarkupText + { + get => DisplayText.Text; + set + { + DisplayText.Text = value; + EditBox.Text = value; + } + } + + public MarkupTextControl() + { + InitializeComponent(); + } + + public void EnterEditMode() + { + textBeforeEdit = DisplayText.Text; + EditBox.Text = DisplayText.Text; + DisplayText.Visibility = Visibility.Collapsed; + EditBox.Visibility = Visibility.Visible; + + // The control may have just been added to the canvas and not be loaded + // yet, in which case Focus() fails — defer until layout has run + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => + { + EditBox.Focus(); + EditBox.SelectAll(); + }); + } + + public void CommitEdit() => FinishEdit(accepted: true); + + public void CancelEdit() => FinishEdit(accepted: false); + + private void FinishEdit(bool accepted) + { + if (!IsEditing) + return; + + string text = EditBox.Text.Trim(); + if (text.Length == 0) + accepted = false; // committing empty text is a cancel + + // Collapse first: the focus shift it triggers re-enters via LostFocus, + // which the IsEditing guard above turns into a no-op + EditBox.Visibility = Visibility.Collapsed; + DisplayText.Visibility = Visibility.Visible; + + if (accepted) + { + DisplayText.Text = text; + EditBox.Text = text; + EditCommitted?.Invoke(this, EventArgs.Empty); + } + else + { + DisplayText.Text = textBeforeEdit; + EditBox.Text = textBeforeEdit; + EditCancelled?.Invoke(this, EventArgs.Empty); + } + } + + private void EditBox_LostFocus(object sender, RoutedEventArgs e) + { + CommitEdit(); + } + + private void EditBox_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + CommitEdit(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + CancelEdit(); + e.Handled = true; + } + } + + private void Border_MouseDown(object sender, MouseButtonEventArgs e) + { + if (IsEditing || e.ChangedButton != MouseButton.Left) + return; + + if (e.ClickCount == 2) + { + EnterEditMode(); + e.Handled = true; + return; + } + + isDragging = true; + dragOffset = e.GetPosition(this); + positionBeforeDrag = new Point(Canvas.GetLeft(this), Canvas.GetTop(this)); + // Capture on the Border (the sender) so its MouseMove/MouseUp handlers + // keep receiving events; capturing the UserControl routes events away + // from the Border and the drag never updates or releases + ((UIElement)sender).CaptureMouse(); + e.Handled = true; + } + + private void Border_MouseMove(object sender, MouseEventArgs e) + { + if (!isDragging || Parent is not Canvas canvas) + return; + + Point parentPos = e.GetPosition(canvas); + Canvas.SetLeft(this, parentPos.X - dragOffset.X); + Canvas.SetTop(this, parentPos.Y - dragOffset.Y); + } + + private void Border_MouseUp(object sender, MouseButtonEventArgs e) + { + if (!isDragging) + return; + + isDragging = false; + ((UIElement)sender).ReleaseMouseCapture(); + e.Handled = true; + + Point positionAfterDrag = new(Canvas.GetLeft(this), Canvas.GetTop(this)); + if (Math.Abs(positionAfterDrag.X - positionBeforeDrag.X) > 0.01 + || Math.Abs(positionAfterDrag.Y - positionBeforeDrag.Y) > 0.01) + { + TextMoved?.Invoke(this, positionBeforeDrag, positionAfterDrag); + } + } + + private void Border_LostMouseCapture(object sender, MouseEventArgs e) + { + isDragging = false; + } + + private void RemoveMenuItem_Click(object sender, RoutedEventArgs e) + { + RemoveControlRequested?.Invoke(this, EventArgs.Empty); + } + + public MarkupTextDto ToDto() + { + return new MarkupTextDto + { + Text = DisplayText.Text, + PositionX = Canvas.GetLeft(this), + PositionY = Canvas.GetTop(this), + TextColor = textColor.ToString(), + FontSize = markupFontSize + }; + } + + public void FromDto(MarkupTextDto dto) + { + MarkupText = dto.Text; + MarkupFontSize = dto.FontSize; + + try { TextColor = (Color)ColorConverter.ConvertFromString(dto.TextColor); } + catch { TextColor = Colors.Red; } + } +} diff --git a/MagickCrop/Controls/RgbHistogramControl.xaml b/MagickCrop/Controls/RgbHistogramControl.xaml new file mode 100644 index 0000000..30e05f0 --- /dev/null +++ b/MagickCrop/Controls/RgbHistogramControl.xaml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/MagickCrop/Controls/RgbHistogramControl.xaml.cs b/MagickCrop/Controls/RgbHistogramControl.xaml.cs new file mode 100644 index 0000000..e22402a --- /dev/null +++ b/MagickCrop/Controls/RgbHistogramControl.xaml.cs @@ -0,0 +1,188 @@ +using ImageMagick; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace MagickCrop.Controls; + +public partial class RgbHistogramControl : UserControl +{ + private long[]? _rBins, _gBins, _bBins; + private CancellationTokenSource? _cts; + private bool _isThresholdActive; + + public bool IsThresholdActive + { + get => _isThresholdActive; + set { _isThresholdActive = value; UpdateThresholdLine(); } + } + + public static readonly DependencyProperty ImagePathProperty = + DependencyProperty.Register( + nameof(ImagePath), + typeof(string), + typeof(RgbHistogramControl), + new PropertyMetadata(null, OnImagePathChanged)); + + public string? ImagePath + { + get => (string?)GetValue(ImagePathProperty); + set => SetValue(ImagePathProperty, value); + } + + public static readonly DependencyProperty ThresholdValueProperty = + DependencyProperty.Register( + nameof(ThresholdValue), + typeof(double), + typeof(RgbHistogramControl), + new PropertyMetadata(128.0, OnThresholdValueChanged)); + + public double ThresholdValue + { + get => (double)GetValue(ThresholdValueProperty); + set => SetValue(ThresholdValueProperty, value); + } + + public RgbHistogramControl() + { + InitializeComponent(); + } + + private static void OnImagePathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((RgbHistogramControl)d).LoadHistogramAsync((string?)e.NewValue); + } + + private static void OnThresholdValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((RgbHistogramControl)d).UpdateThresholdLine(); + } + + private async void LoadHistogramAsync(string? path) + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + CancellationToken token = _cts.Token; + + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + _rBins = _gBins = _bBins = null; + Render(); + return; + } + + long[] r = new long[256], g = new long[256], b = new long[256]; + + try + { + await Task.Run(() => + { + using MagickImage image = new(path); + // Resize to limit memory and speed up computation + if (image.Width > 512 || image.Height > 512) + image.Resize(new MagickGeometry(512, 512) { Greater = true }); + + // ToByteArray scales Q16 values to 0-255 per channel + byte[]? data = image.GetPixelsUnsafe().ToByteArray(PixelMapping.RGB); + if (data is null) return; + + for (int i = 0; i + 2 < data.Length; i += 3) + { + token.ThrowIfCancellationRequested(); + r[data[i]]++; + g[data[i + 1]]++; + b[data[i + 2]]++; + } + }, token); + } + catch (OperationCanceledException) { return; } + + // A newer load may have started while this one was finishing + if (token.IsCancellationRequested) + return; + + _rBins = r; + _gBins = g; + _bBins = b; + Render(); + } + + private void Render() + { + HistogramCanvas.Children.Clear(); + + if (_rBins is null) + { + NoImageText.Visibility = Visibility.Visible; + return; + } + + NoImageText.Visibility = Visibility.Collapsed; + + double w = HistogramCanvas.ActualWidth; + double h = HistogramCanvas.ActualHeight; + if (w <= 0 || h <= 0) return; + + long max = 0; + for (int i = 0; i < 256; i++) + { + max = Math.Max(max, _rBins[i]); + max = Math.Max(max, _gBins![i]); + max = Math.Max(max, _bBins![i]); + } + if (max == 0) return; + + DrawChannel(_rBins, Color.FromArgb(80, 220, 50, 50), Color.FromArgb(180, 220, 50, 50), w, h, max); + DrawChannel(_gBins!, Color.FromArgb(80, 50, 200, 80), Color.FromArgb(180, 50, 200, 80), w, h, max); + DrawChannel(_bBins!, Color.FromArgb(80, 50, 120, 220), Color.FromArgb(180, 50, 120, 220), w, h, max); + + UpdateThresholdLine(); + } + + private void UpdateThresholdLine() + { + double w = HistogramCanvas.ActualWidth; + if (_rBins is null || !_isThresholdActive || w <= 0) + { + ThresholdIndicator.Visibility = Visibility.Collapsed; + return; + } + double x = ThresholdValue / 255.0 * w; + ThresholdIndicator.Margin = new Thickness(x, 0, 0, 0); + ThresholdIndicator.Visibility = Visibility.Visible; + } + + private void DrawChannel(long[] bins, Color fillColor, Color strokeColor, double w, double h, long max) + { + var points = new PointCollection(258) + { + new Point(0, h) + }; + for (int i = 0; i < 256; i++) + { + double x = i / 255.0 * w; + double y = h - (bins[i] / (double)max * h); + points.Add(new Point(x, y)); + } + points.Add(new Point(w, h)); + + var polygon = new Polygon + { + Points = points, + Fill = new SolidColorBrush(fillColor), + Stroke = new SolidColorBrush(strokeColor), + StrokeThickness = 1, + IsHitTestVisible = false, + }; + HistogramCanvas.Children.Add(polygon); + } + + private void HistogramCanvas_SizeChanged(object sender, SizeChangedEventArgs e) + { + Render(); + UpdateThresholdLine(); + } +} diff --git a/MagickCrop/Helpers/WhiteboardInkConverter.cs b/MagickCrop/Helpers/WhiteboardInkConverter.cs new file mode 100644 index 0000000..3b6e580 --- /dev/null +++ b/MagickCrop/Helpers/WhiteboardInkConverter.cs @@ -0,0 +1,550 @@ +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using Emgu.CV.Util; +using System.Drawing; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows.Ink; +using System.Windows.Input; +using WpfColor = System.Windows.Media.Color; + +namespace MagickCrop.Helpers; + +public static class WhiteboardInkConverter +{ + private const int MinComponentPixels = 50; + private const double SimplifyEpsilon = 2.0; + private const int DefaultSpeckleMinArea = 250; + + // HSV saturation threshold: pixels with S > this are considered colored ink + // (blue/red/green markers on white; white background has S ≈ 0) + private const int SaturationThreshold = 30; + + public static async Task> ConvertToStrokesAsync( + string imagePath, double displayWidth, double displayHeight) + { + return await Task.Run(() => Convert(imagePath, displayWidth, displayHeight)); + } + + /// + /// Removes small isolated marks (speckles/dirt) from a whiteboard image. + /// Detects marks via HSV saturation (colored ink) and grayscale darkness. + /// Returns the path to a cleaned temp PNG, or null if no speckles were found. + /// + public static async Task RemoveSpecklesAsync(string imagePath, int minArea = DefaultSpeckleMinArea) + { + return await Task.Run(() => RemoveSpeckles(imagePath, minArea)); + } + + /// + /// Estimates the dominant stroke width in image pixels using the distance transform + /// at skeleton pixels (75th-percentile diameter). Useful for diagnosing why conversion + /// produces strokes that are too thick or too thin. + /// + public static async Task DetectStrokeWidthAsync(string imagePath) + { + return await Task.Run(() => DetectStrokeWidth(imagePath)); + } + + /// + /// Fills hollow interiors of thick whiteboard marker strokes so each stroke is a solid + /// blob before skeletonization. Thick markers often produce "ring" shaped regions in the + /// binary image; this preprocessing step corrects that so the centerline is found cleanly. + /// Returns the path to a modified temp PNG, or null if no hollow regions were found. + /// + public static async Task FillHollowStrokesAsync(string imagePath) + { + return await Task.Run(() => FillHollowStrokes(imagePath)); + } + + // Builds a binary mask where white = ink stroke, black = background. + // Detects BOTH colored markers (via HSV saturation) and dark markers (via adaptive + // grayscale threshold), then OR-combines them. This is critical for colored markers + // (blue, red, etc.) which are not "dark" in grayscale and are missed by the gray-only path. + private static Mat CreateStrokeMask(Mat bgr) + { + using Mat hsv = new(); + CvInvoke.CvtColor(bgr, hsv, ColorConversion.Bgr2Hsv); + + Mat[] hsvChannels = hsv.Split(); + using Mat satChannel = hsvChannels[1]; // S: 0=gray/white, 255=fully saturated color + using Mat valChannel = hsvChannels[2]; // V: brightness + hsvChannels[0].Dispose(); + + // Colored strokes: pixels with meaningful saturation + using Mat satMask = new(); + CvInvoke.Threshold(satChannel, satMask, SaturationThreshold, 255, ThresholdType.Binary); + + // Exclude pixels that are near-pure-white (background bleeds through at S boundary) + using Mat whiteMask = new(); + CvInvoke.Threshold(valChannel, whiteMask, 245, 255, ThresholdType.Binary); + using Mat notWhite = new(); + CvInvoke.BitwiseNot(whiteMask, notWhite); + + using Mat coloredStrokes = new(); + CvInvoke.BitwiseAnd(satMask, notWhite, coloredStrokes); + + // Dark strokes: adaptive threshold on grayscale (catches black markers and edges) + using Mat gray = new(); + CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray); + using Mat darkMask = new(); + CvInvoke.AdaptiveThreshold(gray, darkMask, 255, + AdaptiveThresholdType.GaussianC, ThresholdType.BinaryInv, 11, 5); + + // Combined: colored OR dark + Mat combined = new(); + CvInvoke.BitwiseOr(coloredStrokes, darkMask, combined); + return combined; + } + + private static string? RemoveSpeckles(string imagePath, int minArea) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return null; + + using Mat binary = CreateStrokeMask(bgr); + + using Mat labels = new(); + int numLabels = CvInvoke.ConnectedComponents(binary, labels, LineType.EightConnected, DepthType.Cv32S); + + int rows = labels.Rows; + int cols = labels.Cols; + int labelStep = labels.Step; + byte[] labelsData = new byte[rows * labelStep]; + Marshal.Copy(labels.DataPointer, labelsData, 0, labelsData.Length); + + int[] areaCounts = new int[numLabels]; + for (int y = 0; y < rows; y++) + { + int ry = y * labelStep; + for (int x = 0; x < cols; x++) + { + int lbl = BitConverter.ToInt32(labelsData, ry + x * 4); + if (lbl > 0) areaCounts[lbl]++; + } + } + + bool[] isSpeckle = new bool[numLabels]; + bool anySpeckle = false; + for (int i = 1; i < numLabels; i++) + { + if (areaCounts[i] < minArea) + { + isSpeckle[i] = true; + anySpeckle = true; + } + } + + if (!anySpeckle) return null; + + // Estimate background color from near-white pixels + using Mat gray = new(); + CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray); + using Mat lightMask = new(); + CvInvoke.Threshold(gray, lightMask, 200, 255, ThresholdType.Binary); + MCvScalar bgColor = CvInvoke.Mean(bgr, lightMask); + + using Mat speckleMask = new(bgr.Size, DepthType.Cv8U, 1); + speckleMask.SetTo(new MCvScalar(0)); + int mStep = speckleMask.Step; + byte[] maskData = new byte[rows * mStep]; + + for (int y = 0; y < rows; y++) + { + int ry = y * labelStep; + int my = y * mStep; + for (int x = 0; x < cols; x++) + { + int lbl = BitConverter.ToInt32(labelsData, ry + x * 4); + if (lbl > 0 && isSpeckle[lbl]) + maskData[my + x] = 255; + } + } + Marshal.Copy(maskData, 0, speckleMask.DataPointer, maskData.Length); + + using Mat result = bgr.Clone(); + result.SetTo(bgColor, speckleMask); + + string tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".png"); + CvInvoke.Imwrite(tempPath, result); + return tempPath; + } + + private static double DetectStrokeWidth(string imagePath) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return 0; + + using Mat binary = CreateStrokeMask(bgr); + + using Mat morphKernel = CvInvoke.GetStructuringElement( + MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1)); + using Mat closed = new(); + CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + + using Mat skeleton = Skeletonize(closed); + using Mat dist = new(); + CvInvoke.DistanceTransform(closed, dist, null, DistType.L2, 5); + + int rows = skeleton.Rows, cols = skeleton.Cols; + int skelStep = skeleton.Step, distStep = dist.Step; + + byte[] skelData = new byte[rows * skelStep]; + Marshal.Copy(skeleton.DataPointer, skelData, 0, skelData.Length); + byte[] distData = new byte[rows * distStep]; + Marshal.Copy(dist.DataPointer, distData, 0, distData.Length); + + List radii = []; + for (int y = 0; y < rows; y++) + { + int sy = y * skelStep, dy = y * distStep; + for (int x = 0; x < cols; x++) + { + if (skelData[sy + x] == 0) continue; + float r = BitConverter.ToSingle(distData, dy + x * 4); + if (r > 0.5f) radii.Add(r); + } + } + + if (radii.Count == 0) return 3.0; + radii.Sort(); + return Math.Round(radii[(int)(radii.Count * 0.75)] * 2.0, 1); + } + + private static string? FillHollowStrokes(string imagePath) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return null; + + using Mat binary = CreateStrokeMask(bgr); + + using Mat morphKernel = CvInvoke.GetStructuringElement( + MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1)); + using Mat closed = new(); + CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + + // RETR_EXTERNAL finds only outermost contours; filling them covers hollow interiors + using VectorOfVectorOfPoint contours = new(); + using Mat hierarchy = new(); + CvInvoke.FindContours(closed, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); + + using Mat filledMask = new(bgr.Size, DepthType.Cv8U, 1); + filledMask.SetTo(new MCvScalar(0)); + CvInvoke.DrawContours(filledMask, contours, -1, new MCvScalar(255), -1); + + // Hollow pixels: inside a filled contour but not in the original mask + using Mat notClosed = new(); + CvInvoke.BitwiseNot(closed, notClosed); + using Mat hollowMask = new(); + CvInvoke.BitwiseAnd(filledMask, notClosed, hollowMask); + + if (CvInvoke.CountNonZero(hollowMask) == 0) return null; + + MCvScalar inkColor = CvInvoke.Mean(bgr, closed); + + using Mat result = bgr.Clone(); + result.SetTo(inkColor, hollowMask); + + string tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".png"); + CvInvoke.Imwrite(tempPath, result); + return tempPath; + } + + private static List Convert(string imagePath, double displayWidth, double displayHeight) + { + using Mat bgr = CvInvoke.Imread(imagePath, ImreadModes.AnyColor); + if (bgr.IsEmpty) return []; + + WpfColor penColor = EstimatePenColor(bgr); + + using Mat binary = CreateStrokeMask(bgr); + + using Mat morphKernel = CvInvoke.GetStructuringElement( + MorphShapes.Ellipse, new Size(3, 3), new Point(-1, -1)); + using Mat closed = new(); + CvInvoke.MorphologyEx(binary, closed, MorphOp.Close, morphKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + + using Mat skeleton = Skeletonize(closed); + + using Mat dist = new(); + CvInvoke.DistanceTransform(closed, dist, null, DistType.L2, 5); + + double strokeThickness = EstimateStrokeThickness(dist, skeleton, displayWidth / bgr.Width); + + using Mat labels = new(); + int numLabels = CvInvoke.ConnectedComponents( + skeleton, labels, LineType.EightConnected, DepthType.Cv32S); + + double scaleX = displayWidth / bgr.Width; + double scaleY = displayHeight / bgr.Height; + + Dictionary> components = CollectComponents(labels, numLabels); + List result = []; + + foreach ((int _, List? pixels) in components) + { + if (pixels.Count < MinComponentPixels) continue; + + foreach (List path in ExtractPathsFromSkeleton(pixels)) + { + List simplified = SimplifyPath(path, SimplifyEpsilon); + if (simplified.Count < 2) continue; + + StylusPointCollection spc = new( + simplified.Select(p => new StylusPoint(p.X * scaleX, p.Y * scaleY, 0.5f))); + + DrawingAttributes attrs = new() + { + Color = penColor, + Width = strokeThickness, + Height = strokeThickness, + StylusTip = StylusTip.Ellipse, + }; + + result.Add(new Stroke(spc, attrs)); + } + } + + return result; + } + + // Samples colored (high-saturation) pixels to estimate ink color. + // Falls back to dark-pixel detection for black/gray markers. + private static WpfColor EstimatePenColor(Mat bgr) + { + using Mat hsv = new(); + CvInvoke.CvtColor(bgr, hsv, ColorConversion.Bgr2Hsv); + + Mat[] hsvChannels = hsv.Split(); + using Mat satChannel = hsvChannels[1]; + using Mat valChannel = hsvChannels[2]; + hsvChannels[0].Dispose(); + + using Mat satMask = new(); + CvInvoke.Threshold(satChannel, satMask, SaturationThreshold + 10, 255, ThresholdType.Binary); + + // Exclude very bright pixels (background bleed-through near edges) + using Mat whiteMask = new(); + CvInvoke.Threshold(valChannel, whiteMask, 240, 255, ThresholdType.Binary); + using Mat notWhite = new(); + CvInvoke.BitwiseNot(whiteMask, notWhite); + using Mat strokeMask = new(); + CvInvoke.BitwiseAnd(satMask, notWhite, strokeMask); + + if (CvInvoke.CountNonZero(strokeMask) >= 500) + { + MCvScalar mean = CvInvoke.Mean(bgr, strokeMask); + return WpfColor.FromRgb( + (byte)Math.Clamp(mean.V2, 0, 255), + (byte)Math.Clamp(mean.V1, 0, 255), + (byte)Math.Clamp(mean.V0, 0, 255)); + } + + // Fallback for black/gray markers: sample dark pixels + using Mat gray = new(); + CvInvoke.CvtColor(bgr, gray, ColorConversion.Bgr2Gray); + using Mat darkMask = new(); + CvInvoke.Threshold(gray, darkMask, 76, 255, ThresholdType.BinaryInv); + MCvScalar darkMean = CvInvoke.Mean(bgr, darkMask); + return WpfColor.FromRgb( + (byte)Math.Clamp(darkMean.V2, 0, 255), + (byte)Math.Clamp(darkMean.V1, 0, 255), + (byte)Math.Clamp(darkMean.V0, 0, 255)); + } + + // Collects the distance-transform value at every skeleton pixel, sorts them, and returns + // the 75th-percentile value × 2 as the stroke diameter. + private static double EstimateStrokeThickness(Mat dist, Mat skeleton, double imageToDisplayScale) + { + int rows = skeleton.Rows; + int cols = skeleton.Cols; + int skelStep = skeleton.Step; + int distStep = dist.Step; + + byte[] skelData = new byte[rows * skelStep]; + Marshal.Copy(skeleton.DataPointer, skelData, 0, skelData.Length); + + byte[] distData = new byte[rows * distStep]; + Marshal.Copy(dist.DataPointer, distData, 0, distData.Length); + + List radii = []; + for (int y = 0; y < rows; y++) + { + int sy = y * skelStep; + int dy = y * distStep; + for (int x = 0; x < cols; x++) + { + if (skelData[sy + x] == 0) continue; + float r = BitConverter.ToSingle(distData, dy + x * 4); + if (r > 0.5f) radii.Add(r); + } + } + + if (radii.Count == 0) return 3.0 * imageToDisplayScale; + + radii.Sort(); + float p75 = radii[(int)(radii.Count * 0.75)]; + + return Math.Clamp(p75 * 2.0 * imageToDisplayScale, 1.5, 50.0); + } + + private static Mat Skeletonize(Mat binary) + { + using Mat crossKernel = CvInvoke.GetStructuringElement( + MorphShapes.Cross, new Size(3, 3), new Point(1, 1)); + + Mat skeleton = new(binary.Size, DepthType.Cv8U, 1); + skeleton.SetTo(new MCvScalar(0)); + + Mat remaining = binary.Clone(); + using Mat eroded = new(); + using Mat temp = new(); + + try + { + while (CvInvoke.CountNonZero(remaining) > 0) + { + CvInvoke.Erode(remaining, eroded, crossKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + CvInvoke.Dilate(eroded, temp, crossKernel, + new Point(-1, -1), 1, BorderType.Default, new MCvScalar()); + CvInvoke.Subtract(remaining, temp, temp); + CvInvoke.BitwiseOr(skeleton, temp, skeleton); + eroded.CopyTo(remaining); + } + } + finally + { + remaining.Dispose(); + } + + return skeleton; + } + + private static Dictionary> CollectComponents(Mat labels, int numLabels) + { + int rows = labels.Rows; + int cols = labels.Cols; + int step = labels.Step; + + byte[] rawData = new byte[rows * step]; + Marshal.Copy(labels.DataPointer, rawData, 0, rawData.Length); + + Dictionary> components = new(numLabels); + for (int y = 0; y < rows; y++) + { + int rowOffset = y * step; + for (int x = 0; x < cols; x++) + { + int lbl = BitConverter.ToInt32(rawData, rowOffset + x * 4); + if (lbl <= 0) continue; + if (!components.TryGetValue(lbl, out List? pts)) + { + pts = []; + components[lbl] = pts; + } + pts.Add(new Point(x, y)); + } + } + + return components; + } + + // Extracts ordered path segments from a skeleton connected component by traversing + // the pixel adjacency graph. Splits at junction pixels (3+ neighbors) so each + // returned list is a smooth, non-branching sequence of pixels suitable for a stroke. + private static List> ExtractPathsFromSkeleton(List pixels) + { + if (pixels.Count < 2) return []; + + HashSet pixelSet = [.. pixels]; + + List GetNeighbors(Point p) + { + List ns = []; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + { + if (dx == 0 && dy == 0) continue; + Point n = new(p.X + dx, p.Y + dy); + if (pixelSet.Contains(n)) ns.Add(n); + } + return ns; + } + + Dictionary> adj = new(pixels.Count); + foreach (Point p in pixels) + adj[p] = GetNeighbors(p); + + List endpoints = [.. pixels.Where(p => adj[p].Count == 1)]; + HashSet junctions = [.. pixels.Where(p => adj[p].Count >= 3)]; + + HashSet<(Point, Point)> used = []; + List> paths = []; + + void Follow(Point start, Point first) + { + if (used.Contains((start, first))) return; + + List path = [start, first]; + used.Add((start, first)); + used.Add((first, start)); + + Point prev = start; + Point cur = first; + + while (adj[cur].Count == 2) + { + Point next = default; + bool found = false; + foreach (Point n in adj[cur]) + { + if (n == prev || used.Contains((cur, n))) continue; + next = n; + found = true; + break; + } + if (!found) break; + + used.Add((cur, next)); + used.Add((next, cur)); + path.Add(next); + prev = cur; + cur = next; + } + + if (path.Count >= 3) + paths.Add(path); + } + + foreach (Point ep in endpoints) + foreach (Point n in adj[ep]) + Follow(ep, n); + + foreach (Point junc in junctions) + foreach (Point n in adj[junc]) + Follow(junc, n); + + // Catch isolated loops (all pixels degree 2, no endpoints or junctions) + foreach (Point p in pixels) + foreach (Point n in adj[p]) + if (!used.Contains((p, n))) + Follow(p, n); + + return paths; + } + + private static List SimplifyPath(List points, double epsilon) + { + if (points.Count <= 2) return points; + + using VectorOfPoint input = new([.. points]); + using VectorOfPoint approx = new(); + CvInvoke.ApproxPolyDP(input, approx, epsilon, false); + return [.. approx.ToArray()]; + } +} diff --git a/MagickCrop/MagickCrop.csproj b/MagickCrop/MagickCrop.csproj index 9dbdcb6..111fdd9 100644 --- a/MagickCrop/MagickCrop.csproj +++ b/MagickCrop/MagickCrop.csproj @@ -15,7 +15,7 @@ win-arm64;win-x86;win-x64 false true - 1.11.0 + 1.12.0 true false false @@ -40,16 +40,16 @@ - - - - - - - - + + + + + + + + - + diff --git a/MagickCrop/MainWindow.xaml b/MagickCrop/MainWindow.xaml index 1bdc377..2dbb5c3 100644 --- a/MagickCrop/MainWindow.xaml +++ b/MagickCrop/MainWindow.xaml @@ -121,6 +121,17 @@ IsEnabled="False" IsHitTestVisible="False" Visibility="Collapsed" /> + + Visibility="{Binding HasOpenedFileName, + Converter={StaticResource BoolToVis}}" /> + + + + + - + @@ -595,6 +617,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1032,7 +1475,9 @@ Visibility="Collapsed"> @@ -1725,7 +2170,8 @@ Foreground="#0066FF" IsIndeterminate="True" ToolTip="Processing..." - Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}" /> + Visibility="{Binding IsBusy, + Converter={StaticResource BoolToVis}}" /> verticalLineControls = []; private readonly ObservableCollection horizontalLineControls = []; + // --- Markup state --- + private readonly ObservableCollection markupShapeControls = []; + private MarkupShapeControl? activeMarkupShapeControl; + private readonly ObservableCollection markupTextControls = []; + private System.Windows.Media.Color markupColor = System.Windows.Media.Colors.Red; + private double markupSize = 3.0; + private bool isMarkupPenMode = false; + private bool isMarkupHighlighterMode = false; + private bool isMarkupSelectMode = false; + private bool isMarkupShapeMode = false; + private bool isMarkupTextMode = false; + private Rect? _selectionBoundsBeforeMove = null; + private StrokeCollection? _strokesBeforeMove = null; + private Rect? _selectionBoundsBeforeResize = null; + private StrokeCollection? _strokesBeforeResize = null; + private MagickCrop.Models.MarkupShapeType activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Rectangle; + private bool isMarkupShapeDragCreation = false; + private Point markupShapeBeforePoint1; + private Point markupShapeBeforePoint2; + private int markupShapeBeforeDragIndex = -1; + private Services.RecentProjectsManager? recentProjectsManager; private System.Timers.Timer? autoSaveTimer; private readonly int AutoSaveIntervalMs = (int)TimeSpan.FromSeconds(5).TotalMilliseconds; @@ -139,7 +161,7 @@ void IMainWindowView.SetBusy(bool busy) private bool isFreeRotatingDrag = false; // runtime reference to angle overlay - private WpfTextBlock? rotationOverlayLabel; + private readonly WpfTextBlock? rotationOverlayLabel; private long lastRotateUpdateTicks = 0; private double lastAppliedAdornerAngle = 0.0; private const int RotateUpdateMinIntervalMs = 12; // throttle to reduce UI thrash @@ -197,6 +219,9 @@ public MainWindow() PreviewMouseWheel += ShapeCanvas_PreviewMouseWheel; DrawPolyLine(); + + lines ??= new(); + _polygonElements = [lines, TopLeft, TopRight, BottomRight, BottomLeft]; foreach (UIElement element in _polygonElements) @@ -458,6 +483,43 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) gridStraightenDragIndex = -1; } + if (draggingMode == DraggingMode.MarkupShape && activeMarkupShapeControl is not null) + { + if (isMarkupShapeDragCreation) + { + // New shape added — record undo for the whole addition + MarkupShapeControl ctrl = activeMarkupShapeControl; + UndoRedo.AddUndo(new MarkupShapeAddedItem( + ctrl, markupShapeControls, ShapeCanvas, + wireEvents: () => + { + ctrl.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + ctrl.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + }, + unwireEvents: () => + { + ctrl.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + ctrl.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + })); + } + else if (markupShapeBeforeDragIndex >= 0) + { + // Existing handle dragged — record undo for the point move + var (afterP1, afterP2) = activeMarkupShapeControl.GetPoints(); + Point before = markupShapeBeforeDragIndex == 0 ? markupShapeBeforePoint1 : markupShapeBeforePoint2; + Point after = markupShapeBeforeDragIndex == 0 ? afterP1 : afterP2; + if (before != after) + { + MarkupShapeControl ctrl = activeMarkupShapeControl; + UndoRedo.AddUndo(new MarkupShapePointMovedItem(ctrl, markupShapeBeforeDragIndex, before, after)); + } + } + + activeMarkupShapeControl.ResetActivePoint(); + activeMarkupShapeControl = null; + isMarkupShapeDragCreation = false; + } + clickedElement = null; ReleaseMouseCapture(); draggingMode = DraggingMode.None; @@ -549,6 +611,15 @@ private void TopLeft_MouseMove(object sender, MouseEventArgs e) return; } + if (draggingMode == DraggingMode.MarkupShape && activeMarkupShapeControl is not null) + { + int idx = activeMarkupShapeControl.GetActivePointIndex(); + if (idx >= 0) + activeMarkupShapeControl.MovePoint(idx, movingPoint); + e.Handled = true; + return; + } + if (draggingMode != DraggingMode.MoveElement || clickedElement is null) return; @@ -1076,7 +1147,7 @@ private async void OpenFileButton_Click(object sender, RoutedEventArgs e) OpenFileDialog openFileDialog = new() { - Filter = "Image Files|*.png;*.jpg;*.jpeg;*.heic;*.bmp|All files (*.*)|*.*", + Filter = "Image Files|*.png;*.jpg;*.jpeg;*.heic;*.heif;*.bmp;*.gif;*.tif;*.tiff;*.webp|All files (*.*)|*.*", RestoreDirectory = true, }; @@ -1167,13 +1238,13 @@ private async void CameraButton_Click(object sender, RoutedEventArgs e) SetUiForLongTask(); WelcomeMessageModal.Visibility = Visibility.Collapsed; - var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle; - var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hwnd); + nint hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle; + Microsoft.UI.WindowId windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hwnd); - var cameraCaptureUI = new Microsoft.Windows.Media.Capture.CameraCaptureUI(windowId); - cameraCaptureUI.PhotoSettings.Format = Microsoft.Windows.Media.Capture.CameraCaptureUIPhotoFormat.Png; + CameraCaptureUI cameraCaptureUI = new(windowId); + cameraCaptureUI.PhotoSettings.Format = CameraCaptureUIPhotoFormat.Png; - var file = await cameraCaptureUI.CaptureFileAsync(Microsoft.Windows.Media.Capture.CameraCaptureUIMode.Photo); + StorageFile file = await cameraCaptureUI.CaptureFileAsync(CameraCaptureUIMode.Photo); if (file != null) { @@ -1296,6 +1367,34 @@ await Task.Run(async () => CenterAndZoomToFit(); } + private async void ResetToOriginalMenuItem_Click(object sender, RoutedEventArgs e) + { + string? originalPath = ViewModel.OriginalFilePath; + if (string.IsNullOrWhiteSpace(originalPath) || !File.Exists(originalPath)) + { + Wpf.Ui.Controls.MessageBox uiMessageBox = new() + { + Title = "Reset to Original", + Content = "The original image file could no longer be found, so the image cannot be reset.", + }; + await uiMessageBox.ShowDialogAsync(); + return; + } + + Wpf.Ui.Controls.MessageBox confirmBox = new() + { + Title = "Reset to Original", + Content = "This will discard all edits and measurements and reload the original image. Continue?", + PrimaryButtonText = "Reset", + CloseButtonText = "Cancel", + }; + + if (await confirmBox.ShowDialogAsync() != Wpf.Ui.Controls.MessageBoxResult.Primary) + return; + + await OpenImagePath(originalPath); + } + private const double ZoomFactor = 0.1; private const double MinZoom = 0.1; private const double MaxZoom = 10.0; @@ -1448,6 +1547,15 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e) clickedPoint = e.GetPosition(ShapeCanvas); + // A markup text edit in progress absorbs this click: commit it instead of + // starting a new tool action (otherwise clicking away to accept the text + // would immediately place another text box) + if (CommitPendingMarkupTextEdit()) + { + e.Handled = true; + return; + } + // --- ANGLE MEASUREMENT PLACEMENT LOGIC --- if (isPlacingAngleMeasurement && anglePlacementStep == AnglePlacementStep.PlacingThirdPoint && activeAnglePlacementControl != null) { @@ -1468,6 +1576,73 @@ private void ShapeCanvas_MouseDown(object sender, MouseButtonEventArgs e) return; } + // --- MARKUP SHAPE PLACEMENT --- + if (isMarkupShapeMode && e.LeftButton == MouseButtonState.Pressed) + { + MarkupShapeControl shapeControl = new() + { + ShapeType = activeMarkupShapeType, + StrokeColor = markupColor, + StrokeThickness = markupSize + }; + shapeControl.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + shapeControl.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + markupShapeControls.Add(shapeControl); + ShapeCanvas.Children.Add(shapeControl); + shapeControl.MovePoint(0, clickedPoint); + shapeControl.StartDraggingPoint(1); // fires MarkupShapePoint_MouseDown → CaptureMouse + isMarkupShapeDragCreation = true; // distinguishes creation from handle move for undo + draggingMode = DraggingMode.MarkupShape; + e.Handled = true; + return; + } + + // --- MARKUP TEXT PLACEMENT --- + if (isMarkupTextMode && e.LeftButton == MouseButtonState.Pressed) + { + MarkupTextControl textControl = new() + { + TextColor = markupColor, + MarkupFontSize = markupSize * 4 + }; + textControl.RemoveControlRequested += MarkupTextControl_RemoveControlRequested; + Canvas.SetLeft(textControl, clickedPoint.X); + Canvas.SetTop(textControl, clickedPoint.Y); + markupTextControls.Add(textControl); + ShapeCanvas.Children.Add(textControl); + + // Push the undo item only once the initial edit is committed; cancelling + // (Escape, or committing empty text) discards the control entirely + MarkupTextControl ctrl = textControl; + void OnFirstCommit(object? s, EventArgs args) + { + ctrl.EditCommitted -= OnFirstCommit; + ctrl.EditCancelled -= OnFirstCancel; + UndoRedo.AddUndo(new MarkupTextAddedItem( + ctrl, markupTextControls, ShapeCanvas, + wireEvents: () => ctrl.RemoveControlRequested += MarkupTextControl_RemoveControlRequested, + unwireEvents: () => ctrl.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested)); + + // From now on, edits and drags of this label get their own undo items + ctrl.EditCommitted += MarkupTextControl_EditCommitted; + ctrl.TextMoved += MarkupTextControl_TextMoved; + } + void OnFirstCancel(object? s, EventArgs args) + { + ctrl.EditCommitted -= OnFirstCommit; + ctrl.EditCancelled -= OnFirstCancel; + ctrl.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + markupTextControls.Remove(ctrl); + ShapeCanvas.Children.Remove(ctrl); + } + ctrl.EditCommitted += OnFirstCommit; + ctrl.EditCancelled += OnFirstCancel; + + textControl.EnterEditMode(); + e.Handled = true; + return; + } + if (MeasureDistanceToggle.IsChecked is true) { double scale = ScaleInput.Value ?? 1.0; @@ -3903,6 +4078,7 @@ private void ShowObjectEraseControls() HideEdgeCorrectionControls(); HideGridStraightenControls(); + HideThresholdControls(); isObjectEraseMode = true; ObjectEraseButtonPanel.Visibility = Visibility.Visible; @@ -3933,6 +4109,38 @@ private void HideObjectEraseControls() EraseMaskCanvas.IsHitTestVisible = false; } + private void ThresholdMenuItem_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) + return; + + ShowThresholdControls(); + } + + private void ShowThresholdControls() + { + HideObjectEraseControls(); + ThresholdPanel.Visibility = Visibility.Visible; + EditHistogram.IsThresholdActive = true; + } + + private void HideThresholdControls() + { + ThresholdPanel.Visibility = Visibility.Collapsed; + EditHistogram.IsThresholdActive = false; + } + + private async void ApplyThresholdButton_Click(object sender, RoutedEventArgs e) + { + await ViewModel.ApplyThresholdCommand.ExecuteAsync(null); + HideThresholdControls(); + } + + private void CancelThresholdButton_Click(object sender, RoutedEventArgs e) + { + HideThresholdControls(); + } + private void EraseBrushSizeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) { if (EraseMaskCanvas is null) @@ -4117,7 +4325,6 @@ private async void MeasurementControl_SetRealWorldLengthRequested(object sender, }; // Show the dialog and handle the result - ContentDialogService dialogService = new(); dialog.DialogHost = Presenter; dialog.Closing += (s, args) => { @@ -4205,6 +4412,7 @@ private void RemoveMeasurementControls() horizontalLineControls.Clear(); ClearAllStrokesAndLengths(); + ClearAllMarkup(); draggingMode = DraggingMode.None; } @@ -4213,7 +4421,7 @@ private bool HandleMeasurementMouseDown(object sender, MouseButtonEventArgs? { if (isAdornerRotatingDrag) { - if (e is not null) e.Handled = true; + e?.Handled = true; return false; } if (sender is Ellipse senderEllipse @@ -4248,6 +4456,20 @@ private void PolygonMeasurementPoint_MouseDown(object sender, MouseButtonEventAr private void CircleMeasurementPoint_MouseDown(object sender, MouseButtonEventArgs e) => HandleMeasurementMouseDown(sender, e, DraggingMode.MeasureCircle, c => activeCircleMeasureControl = c); + private void MarkupShapePoint_MouseDown(object sender, MouseButtonEventArgs? e) + { + if (HandleMeasurementMouseDown(sender, e, DraggingMode.MarkupShape, c => activeMarkupShapeControl = c)) + { + // Capture before-state for point-move undo (overridden to true by creation caller if needed) + isMarkupShapeDragCreation = false; + if (activeMarkupShapeControl is not null) + { + (markupShapeBeforePoint1, markupShapeBeforePoint2) = activeMarkupShapeControl.GetPoints(); + markupShapeBeforeDragIndex = activeMarkupShapeControl.GetActivePointIndex(); + } + } + } + private async void SetImageScaleButton_Click(object sender, RoutedEventArgs e) { if (MainImage.Source is not BitmapSource bitmap) @@ -4551,6 +4773,15 @@ private MagickCropMeasurementPackage BuildCurrentPackage(PackageMetadata? metada package.Measurements.StrokeInfos.Add(StrokeInfoDto.FromStrokeInfo(info, displayX, displayY)); } + foreach (MarkupShapeControl control in markupShapeControls) + package.Measurements.MarkupShapes.Add(control.ToDto()); + + foreach (MarkupTextControl control in markupTextControls) + package.Measurements.MarkupTexts.Add(control.ToDto()); + + foreach (Stroke stroke in MarkupCanvas.Strokes) + package.Measurements.MarkupStrokes.Add(MarkupStrokeDto.FromStroke(stroke)); + return package; } @@ -4818,6 +5049,36 @@ private async Task LoadMeasurementPackageAsync(string fileName) strokeMeasurements.Add(stroke, infoDto.ToStrokeInfo()); } + // Restore markup shapes + foreach (MarkupShapeDto dto in package.Measurements.MarkupShapes) + { + MarkupShapeControl control = new(); + control.FromDto(dto); + control.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + control.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + markupShapeControls.Add(control); + ShapeCanvas.Children.Add(control); + } + + // Restore markup text annotations + foreach (MarkupTextDto dto in package.Measurements.MarkupTexts) + { + MarkupTextControl control = new(); + control.FromDto(dto); + control.RemoveControlRequested += MarkupTextControl_RemoveControlRequested; + control.EditCommitted += MarkupTextControl_EditCommitted; + control.TextMoved += MarkupTextControl_TextMoved; + Canvas.SetLeft(control, dto.PositionX); + Canvas.SetTop(control, dto.PositionY); + markupTextControls.Add(control); + ShapeCanvas.Children.Add(control); + } + + // Restore markup canvas strokes + MarkupCanvas.Strokes.Clear(); + foreach (MarkupStrokeDto dto in package.Measurements.MarkupStrokes) + MarkupCanvas.Strokes.Add(dto.ToStroke()); + if (package?.Metadata?.ProjectId is not null) ViewModel.CurrentProjectId = package.Metadata.ProjectId; else @@ -5082,6 +5343,7 @@ private void ResetApplicationState() HideCroppingControls(); HideResizeControls(); HideObjectEraseControls(); + HideThresholdControls(); BottomBorder.Visibility = Visibility.Collapsed; WelcomeMessageModal.Visibility = Visibility.Visible; OpenFolderButton.IsEnabled = false; @@ -5464,8 +5726,12 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e) } } + // While a text box has keyboard focus (e.g. editing a markup text), + // leave Ctrl+Z/Ctrl+Y to the text box's own undo + bool typingInTextBox = Keyboard.FocusedElement is System.Windows.Controls.Primitives.TextBoxBase; + // Handle Ctrl+Z for undo - if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Z) + if (!typingInTextBox && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Z) { if (UndoRedo.CanUndo) { @@ -5476,7 +5742,7 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e) } // Handle Ctrl+Y for redo - if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Y) + if (!typingInTextBox && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.Y) { if (UndoRedo.CanRedo) { @@ -5486,8 +5752,31 @@ private void FluentWindow_PreviewKeyDown(object sender, KeyEventArgs e) } } + // Handle Delete key for selected markup ink strokes + if (e.Key == Key.Delete && isMarkupSelectMode) + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count > 0) + { + DeleteSelectedMarkupStrokes(); + e.Handled = true; + return; + } + } + if (e.Key == Key.Escape) { + // Escape while editing a markup text cancels just that edit + foreach (MarkupTextControl control in markupTextControls.ToList()) + { + if (control.IsEditing) + { + control.CancelEdit(); + e.Handled = true; + return; + } + } + UncheckAllBut(); // Cancel white point picker mode @@ -6112,6 +6401,609 @@ DraggingMode.MeasurePolygon or } #endregion Pixel Precision Zoom + + #region Markup Tab + + private void ToolsTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (e.Source is not TabControl) + return; + + bool markupTabActive = MarkupTabItem?.IsSelected == true; + if (!markupTabActive) + DeactivateAllMarkupTools(); + } + + private void DeactivateAllMarkupTools() + { + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + if (MarkupCanvas is not null) + { + MarkupCanvas.IsEnabled = false; + MarkupCanvas.IsHitTestVisible = false; + } + UncheckMarkupAllBut(); + } + + private void UncheckMarkupAllBut(ToggleButton? keep = null) + { + if (MarkupToolsPanel is null || MarkupShapeToolsPanel is null) return; + foreach (ToggleButton btn in MarkupToolsPanel.Children.OfType()) + if (btn != keep) btn.IsChecked = false; + foreach (ToggleButton btn in MarkupShapeToolsPanel.Children.OfType()) + if (btn != keep) btn.IsChecked = false; + } + + private void UpdateMarkupCanvasForPen() + { + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.Ink; + DrawingAttributes attrs = new() + { + Color = markupColor, + Width = markupSize, + Height = markupSize, + IsHighlighter = false, + StylusTip = StylusTip.Ellipse + }; + MarkupCanvas.DefaultDrawingAttributes = attrs; + } + + private void UpdateMarkupCanvasForHighlighter() + { + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.Ink; + System.Windows.Media.Color highlightColor = markupColor; + highlightColor.A = 100; + DrawingAttributes attrs = new() + { + Color = highlightColor, + Width = markupSize * 6, + Height = markupSize * 6, + IsHighlighter = true, + StylusTip = StylusTip.Rectangle + }; + MarkupCanvas.DefaultDrawingAttributes = attrs; + } + + private void DisableMarkupCanvas() + { + MarkupCanvas.IsEnabled = false; + MarkupCanvas.IsHitTestVisible = false; + } + + private void MarkupPenToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupPenMode = true; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + UpdateMarkupCanvasForPen(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupHighlighterToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupHighlighterMode = true; + isMarkupPenMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + UpdateMarkupCanvasForHighlighter(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupEraserToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.EraseByStroke; + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupSelectToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupSelectMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + MarkupCanvas.IsEnabled = true; + MarkupCanvas.IsHitTestVisible = true; + MarkupCanvas.EditingMode = InkCanvasEditingMode.Select; + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupLineToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Line; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupArrowToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Arrow; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupRectangleToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Rectangle; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupEllipseToggle_Checked(object sender, RoutedEventArgs e) + { + activeMarkupShapeType = MagickCrop.Models.MarkupShapeType.Ellipse; + isMarkupShapeMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupTextToggle_Checked(object sender, RoutedEventArgs e) + { + isMarkupTextMode = true; + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + DisableMarkupCanvas(); + UncheckMarkupAllBut(sender as ToggleButton); + } + + private void MarkupToolToggle_Clicked(object sender, RoutedEventArgs e) + { + if (sender is not ToggleButton toggle || toggle.IsChecked is true) + return; + + isMarkupPenMode = false; + isMarkupHighlighterMode = false; + isMarkupSelectMode = false; + isMarkupShapeMode = false; + isMarkupTextMode = false; + DisableMarkupCanvas(); + draggingMode = DraggingMode.None; + } + + private void MarkupToolToggle_Unchecked(object sender, RoutedEventArgs e) + { + // Handled by MarkupToolToggle_Clicked + } + + private void MarkupColorButton_Checked(object sender, RoutedEventArgs e) + { + if (sender is not ToggleButton btn || btn.Tag is not string colorName) + return; + + // Uncheck other color buttons + foreach (ToggleButton other in MarkupColorPalette.Children.OfType()) + if (other != btn) other.IsChecked = false; + + try + { + markupColor = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(colorName); + } + catch + { + markupColor = System.Windows.Media.Colors.Red; + } + + // Apply color to active ink tool immediately + if (isMarkupPenMode) UpdateMarkupCanvasForPen(); + else if (isMarkupHighlighterMode) UpdateMarkupCanvasForHighlighter(); + else if (isMarkupSelectMode) ApplyColorToSelectedStrokes(); + } + + private void MarkupSizeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + markupSize = e.NewValue; + + if (isMarkupPenMode) UpdateMarkupCanvasForPen(); + else if (isMarkupHighlighterMode) UpdateMarkupCanvasForHighlighter(); + else if (isMarkupSelectMode) ApplySizeToSelectedStrokes(); + } + + private void ApplyColorToSelectedStrokes() + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count == 0) return; + + List<(Stroke, DrawingAttributes, DrawingAttributes)> changes = []; + foreach (Stroke stroke in selected) + { + DrawingAttributes before = stroke.DrawingAttributes.Clone(); + System.Windows.Media.Color color = markupColor; + if (stroke.DrawingAttributes.IsHighlighter) + color.A = 100; + DrawingAttributes after = stroke.DrawingAttributes.Clone(); + after.Color = color; + stroke.DrawingAttributes = after; + changes.Add((stroke, before, after)); + } + UndoRedo.AddUndo(new MarkupStrokePropertiesChangedItem(changes)); + } + + private void ApplySizeToSelectedStrokes() + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count == 0) return; + + List<(Stroke, DrawingAttributes, DrawingAttributes)> changes = []; + foreach (Stroke stroke in selected) + { + DrawingAttributes before = stroke.DrawingAttributes.Clone(); + double size = stroke.DrawingAttributes.IsHighlighter ? markupSize * 6 : markupSize; + DrawingAttributes after = stroke.DrawingAttributes.Clone(); + after.Width = size; + after.Height = size; + stroke.DrawingAttributes = after; + changes.Add((stroke, before, after)); + } + UndoRedo.AddUndo(new MarkupStrokePropertiesChangedItem(changes)); + } + + private void MarkupCanvas_StrokeCollected(object sender, InkCanvasStrokeCollectedEventArgs e) + { + UndoRedo.AddUndo(new MarkupStrokeAddedItem(MarkupCanvas, e.Stroke)); + } + + private void MarkupCanvas_StrokeErasing(object sender, InkCanvasStrokeErasingEventArgs e) + { + UndoRedo.AddUndo(new MarkupStrokeDeletedItem(MarkupCanvas, [e.Stroke])); + } + + private void MarkupCanvas_SelectionMoving(object sender, InkCanvasSelectionEditingEventArgs e) + { + _selectionBoundsBeforeMove = e.OldRectangle; + _strokesBeforeMove = new StrokeCollection(MarkupCanvas.GetSelectedStrokes()); + } + + private void MarkupCanvas_SelectionMoved(object sender, EventArgs e) + { + if (_strokesBeforeMove is null || _selectionBoundsBeforeMove is null) return; + + Rect newBounds = MarkupCanvas.GetSelectionBounds(); + double deltaX = newBounds.X - _selectionBoundsBeforeMove.Value.X; + 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)); + + _strokesBeforeMove = null; + _selectionBoundsBeforeMove = null; + } + + private void MarkupCanvas_SelectionResizing(object sender, InkCanvasSelectionEditingEventArgs e) + { + _selectionBoundsBeforeResize = e.OldRectangle; + _strokesBeforeResize = new StrokeCollection(MarkupCanvas.GetSelectedStrokes()); + } + + private void MarkupCanvas_SelectionResized(object sender, EventArgs e) + { + if (_strokesBeforeResize is null || _selectionBoundsBeforeResize is null) return; + + Rect oldBounds = _selectionBoundsBeforeResize.Value; + Rect newBounds = MarkupCanvas.GetSelectionBounds(); + + if (oldBounds.Width > 0 && oldBounds.Height > 0 + && (Math.Abs(newBounds.X - oldBounds.X) > 0.01 + || Math.Abs(newBounds.Y - oldBounds.Y) > 0.01 + || Math.Abs(newBounds.Width - oldBounds.Width) > 0.01 + || Math.Abs(newBounds.Height - oldBounds.Height) > 0.01)) + { + UndoRedo.AddUndo(new MarkupStrokeResizedItem(_strokesBeforeResize, oldBounds, newBounds)); + } + + _strokesBeforeResize = null; + _selectionBoundsBeforeResize = null; + } + + private void DeleteSelectedMarkupStrokes() + { + StrokeCollection selected = MarkupCanvas.GetSelectedStrokes(); + if (selected.Count == 0) return; + UndoRedo.AddUndo(new MarkupStrokeDeletedItem(MarkupCanvas, selected)); + foreach (Stroke stroke in selected.ToList()) + MarkupCanvas.Strokes.Remove(stroke); + } + + private async void FillHollowStrokesButton_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) return; + + FillHollowStrokesButton.IsEnabled = false; + FillHollowStrokesProgressRing.Visibility = Visibility.Visible; + + try + { + string? resultPath = await WhiteboardInkConverter.FillHollowStrokesAsync(ViewModel.ImagePath); + + if (resultPath is null) + { + System.Windows.MessageBox.Show( + "No hollow stroke interiors were found in the image.", + "Fill Hollow Strokes", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, resultPath); + UndoRedo.AddUndo(undoRedoItem); + + ViewModel.ImagePath = resultPath; + using MagickImage resultImage = new(resultPath); + MainImage.Source = resultImage.ToBitmapSource(); + ViewModel.ActualImageSize = new Size(resultImage.Width, resultImage.Height); + } + catch (Exception ex) + { + System.Windows.MessageBox.Show( + $"Fill Hollow Strokes failed: {ex.Message}", + "Error", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + FillHollowStrokesButton.IsEnabled = true; + FillHollowStrokesProgressRing.Visibility = Visibility.Collapsed; + } + } + + private async void ConvertToInkButton_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrEmpty(ViewModel.ImagePath)) return; + + ConvertToInkButton.IsEnabled = false; + ConvertToInkProgressRing.Visibility = Visibility.Visible; + + try + { + List strokes = await WhiteboardInkConverter.ConvertToStrokesAsync( + ViewModel.ImagePath, + MarkupCanvas.ActualWidth, + MarkupCanvas.ActualHeight); + + if (strokes.Count == 0) return; + + foreach (Stroke stroke in strokes) + MarkupCanvas.Strokes.Add(stroke); + + UndoRedo.AddUndo(new MarkupStrokeBatchAddedItem(MarkupCanvas, strokes)); + } + finally + { + ConvertToInkButton.IsEnabled = true; + ConvertToInkProgressRing.Visibility = Visibility.Collapsed; + } + } + + private async void CleanWhiteboardButton_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrWhiteSpace(ViewModel.ImagePath)) return; + + CleanWhiteboardButton.IsEnabled = false; + CleanWhiteboardProgressRing.Visibility = Visibility.Visible; + + try + { + string? resultPath = await WhiteboardInkConverter.RemoveSpecklesAsync(ViewModel.ImagePath); + + if (resultPath is null) + { + System.Windows.MessageBox.Show( + "No small speckles were found in the image.", + "Clean Whiteboard", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + MagickImageUndoRedoItem undoRedoItem = new(MainImage, ViewModel.ImagePath, resultPath); + UndoRedo.AddUndo(undoRedoItem); + + ViewModel.ImagePath = resultPath; + using MagickImage resultImage = new(resultPath); + MainImage.Source = resultImage.ToBitmapSource(); + ViewModel.ActualImageSize = new Size(resultImage.Width, resultImage.Height); + } + catch (Exception ex) + { + System.Windows.MessageBox.Show( + $"Clean Whiteboard failed: {ex.Message}", + "Error", + System.Windows.MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + CleanWhiteboardButton.IsEnabled = true; + CleanWhiteboardProgressRing.Visibility = Visibility.Collapsed; + } + } + + private void MarkupShapeControl_RemoveControlRequested(object sender, EventArgs e) + { + if (sender is not MarkupShapeControl control) return; + control.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + control.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + markupShapeControls.Remove(control); + ShapeCanvas.Children.Remove(control); + + UndoRedo.AddUndo(new MarkupControlRemovedItem( + control, markupShapeControls, ShapeCanvas, + wireEvents: () => + { + control.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + control.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + }, + unwireEvents: () => + { + control.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + control.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + })); + } + + private void MarkupTextControl_EditCommitted(object? sender, EventArgs e) + { + if (sender is not MarkupTextControl control) return; + if (control.TextBeforeEdit != control.MarkupText) + UndoRedo.AddUndo(new MarkupTextChangedItem(control, control.TextBeforeEdit, control.MarkupText)); + } + + private void MarkupTextControl_TextMoved(object sender, Point before, Point after) + { + if (sender is not MarkupTextControl control) return; + UndoRedo.AddUndo(new MarkupTextMovedItem(control, before, after)); + } + + /// + /// Commits any markup text control still in edit mode. Returns true if one was open. + /// + private bool CommitPendingMarkupTextEdit() + { + bool committed = false; + // Committing empty text cancels the edit, which can remove the control + // from the collection — iterate over a copy + foreach (MarkupTextControl control in markupTextControls.ToList()) + { + if (control.IsEditing) + { + control.CommitEdit(); + committed = true; + } + } + + return committed; + } + + private void MarkupTextControl_RemoveControlRequested(object sender, EventArgs e) + { + if (sender is not MarkupTextControl control) return; + control.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + markupTextControls.Remove(control); + ShapeCanvas.Children.Remove(control); + + UndoRedo.AddUndo(new MarkupControlRemovedItem( + control, markupTextControls, ShapeCanvas, + wireEvents: () => control.RemoveControlRequested += MarkupTextControl_RemoveControlRequested, + unwireEvents: () => control.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested)); + } + + private void ClearAllMarkup() + { + foreach (MarkupShapeControl control in markupShapeControls.ToList()) + { + control.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + control.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + ShapeCanvas.Children.Remove(control); + } + markupShapeControls.Clear(); + + foreach (MarkupTextControl control in markupTextControls.ToList()) + { + control.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + ShapeCanvas.Children.Remove(control); + } + markupTextControls.Clear(); + + MarkupCanvas.Strokes.Clear(); + } + + private void ClearMarkupButton_Click(object sender, RoutedEventArgs e) + { + if (markupShapeControls.Count == 0 && markupTextControls.Count == 0 && MarkupCanvas.Strokes.Count == 0) + return; + + List shapes = [.. markupShapeControls]; + List texts = [.. markupTextControls]; + List strokes = [.. MarkupCanvas.Strokes]; + + UndoRedo.AddUndo(new MarkupClearedItem( + shapes, texts, strokes, + markupShapeControls, markupTextControls, + ShapeCanvas, MarkupCanvas, + wireEvents: () => + { + foreach (MarkupShapeControl shape in shapes) + { + shape.MeasurementPointMouseDown += MarkupShapePoint_MouseDown; + shape.RemoveControlRequested += MarkupShapeControl_RemoveControlRequested; + } + foreach (MarkupTextControl text in texts) + text.RemoveControlRequested += MarkupTextControl_RemoveControlRequested; + }, + unwireEvents: () => + { + foreach (MarkupShapeControl shape in shapes) + { + shape.MeasurementPointMouseDown -= MarkupShapePoint_MouseDown; + shape.RemoveControlRequested -= MarkupShapeControl_RemoveControlRequested; + } + foreach (MarkupTextControl text in texts) + text.RemoveControlRequested -= MarkupTextControl_RemoveControlRequested; + })); + + ClearAllMarkup(); + } + + private void HideMarkupToggle_Checked(object sender, RoutedEventArgs e) + { + SetMarkupVisibility(false); + } + + private void HideMarkupToggle_Unchecked(object sender, RoutedEventArgs e) + { + SetMarkupVisibility(true); + } + + private void SetMarkupVisibility(bool visible) + { + Visibility v = visible ? Visibility.Visible : Visibility.Collapsed; + foreach (MarkupShapeControl control in markupShapeControls) + control.Visibility = v; + foreach (MarkupTextControl control in markupTextControls) + control.Visibility = v; + MarkupCanvas.Visibility = v; + } + + #endregion Markup Tab } internal enum AnglePlacementStep { diff --git a/MagickCrop/Models/DraggingMode.cs b/MagickCrop/Models/DraggingMode.cs index d5561f5..47a4091 100644 --- a/MagickCrop/Models/DraggingMode.cs +++ b/MagickCrop/Models/DraggingMode.cs @@ -15,5 +15,7 @@ public enum DraggingMode WhitePointPicker, BlackPointPicker, EdgeCorrectionDragging, - GridStraightenDragging + GridStraightenDragging, + MarkupShape, + MarkupText } diff --git a/MagickCrop/Models/MarkupShapeType.cs b/MagickCrop/Models/MarkupShapeType.cs new file mode 100644 index 0000000..1e6333e --- /dev/null +++ b/MagickCrop/Models/MarkupShapeType.cs @@ -0,0 +1,9 @@ +namespace MagickCrop.Models; + +public enum MarkupShapeType +{ + Line, + Arrow, + Rectangle, + Ellipse +} diff --git a/MagickCrop/Models/MeasurementControls/MarkupShapeDto.cs b/MagickCrop/Models/MeasurementControls/MarkupShapeDto.cs new file mode 100644 index 0000000..97be5a9 --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/MarkupShapeDto.cs @@ -0,0 +1,18 @@ +using MagickCrop.Models; +using System.Windows; + +namespace MagickCrop.Models.MeasurementControls; + +public class MarkupShapeDto : MeasurementControlDto +{ + public MarkupShapeDto() + { + Type = "MarkupShape"; + } + + public MarkupShapeType ShapeType { get; set; } + public Point Point1 { get; set; } + public Point Point2 { get; set; } + public string StrokeColor { get; set; } = "#FFFF0000"; + public double StrokeThickness { get; set; } = 3.0; +} diff --git a/MagickCrop/Models/MeasurementControls/MarkupStrokeDto.cs b/MagickCrop/Models/MeasurementControls/MarkupStrokeDto.cs new file mode 100644 index 0000000..12c903d --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/MarkupStrokeDto.cs @@ -0,0 +1,38 @@ +using System.Windows; +using System.Windows.Ink; +using System.Windows.Input; +using System.Windows.Media; + +namespace MagickCrop.Models.MeasurementControls; + +public class MarkupStrokeDto +{ + public List Points { get; set; } = []; + public Color Color { get; set; } + public double Thickness { get; set; } + public bool IsHighlighter { get; set; } + + public static MarkupStrokeDto FromStroke(Stroke stroke) + { + return new MarkupStrokeDto + { + Points = [.. stroke.StylusPoints.Select(sp => new Point(sp.X, sp.Y))], + Color = stroke.DrawingAttributes.Color, + Thickness = stroke.DrawingAttributes.Width, + IsHighlighter = stroke.DrawingAttributes.IsHighlighter + }; + } + + public Stroke ToStroke() + { + StylusPointCollection stylusPoints = [.. Points.Select(p => new StylusPoint(p.X, p.Y))]; + DrawingAttributes attrs = new() + { + Color = Color, + Width = Thickness, + Height = Thickness, + IsHighlighter = IsHighlighter + }; + return new Stroke(stylusPoints, attrs); + } +} diff --git a/MagickCrop/Models/MeasurementControls/MarkupTextDto.cs b/MagickCrop/Models/MeasurementControls/MarkupTextDto.cs new file mode 100644 index 0000000..152b936 --- /dev/null +++ b/MagickCrop/Models/MeasurementControls/MarkupTextDto.cs @@ -0,0 +1,15 @@ +namespace MagickCrop.Models.MeasurementControls; + +public class MarkupTextDto : MeasurementControlDto +{ + public MarkupTextDto() + { + Type = "MarkupText"; + } + + public string Text { get; set; } = string.Empty; + public double PositionX { get; set; } + public double PositionY { get; set; } + public string TextColor { get; set; } = "#FFFF0000"; + public double FontSize { get; set; } = 16.0; +} diff --git a/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs b/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs index 101e40d..1ba3d9e 100644 --- a/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs +++ b/MagickCrop/Models/MeasurementControls/MeasurementCollection.cs @@ -56,6 +56,21 @@ public class MeasurementCollection /// public List StrokeInfos { get; set; } = []; + /// + /// Collection of markup shape overlay controls + /// + public List MarkupShapes { get; set; } = []; + + /// + /// Collection of markup text annotation controls + /// + public List MarkupTexts { get; set; } = []; + + /// + /// Collection of markup canvas ink strokes (pen and highlighter) + /// + public List MarkupStrokes { get; set; } = []; + /// /// Global scale factor applied to all distance measurements /// diff --git a/MagickCrop/Models/UndoRedo.cs b/MagickCrop/Models/UndoRedo.cs index 1064286..84ade88 100644 --- a/MagickCrop/Models/UndoRedo.cs +++ b/MagickCrop/Models/UndoRedo.cs @@ -1,8 +1,11 @@ using ImageMagick; +using MagickCrop.Controls; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; +using System.Windows.Ink; using System.Windows.Media; namespace MagickCrop; @@ -102,6 +105,448 @@ public override string Redo() } } +public class MarkupShapeAddedItem : UndoRedoItem +{ + private readonly MarkupShapeControl _control; + private readonly ObservableCollection _collection; + private readonly Canvas _canvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupShapeAddedItem( + MarkupShapeControl control, + ObservableCollection collection, + Canvas canvas, + Action wireEvents, + Action unwireEvents) + { + _control = control; + _collection = collection; + _canvas = canvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _unwireEvents(); + _collection.Remove(_control); + _canvas.Children.Remove(_control); + return string.Empty; + } + + public override string Redo() + { + _wireEvents(); + _collection.Add(_control); + _canvas.Children.Add(_control); + return string.Empty; + } +} + +public class MarkupShapePointMovedItem : UndoRedoItem +{ + private readonly MarkupShapeControl _control; + private readonly int _pointIndex; + private readonly Point _before; + private readonly Point _after; + + public MarkupShapePointMovedItem(MarkupShapeControl control, int pointIndex, Point before, Point after) + { + _control = control; + _pointIndex = pointIndex; + _before = before; + _after = after; + } + + public override string Undo() + { + _control.MovePoint(_pointIndex, _before); + return string.Empty; + } + + public override string Redo() + { + _control.MovePoint(_pointIndex, _after); + return string.Empty; + } +} + +public class MarkupTextAddedItem : UndoRedoItem +{ + private readonly MarkupTextControl _control; + private readonly ObservableCollection _collection; + private readonly Canvas _canvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupTextAddedItem( + MarkupTextControl control, + ObservableCollection collection, + Canvas canvas, + Action wireEvents, + Action unwireEvents) + { + _control = control; + _collection = collection; + _canvas = canvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _unwireEvents(); + _collection.Remove(_control); + _canvas.Children.Remove(_control); + return string.Empty; + } + + public override string Redo() + { + _wireEvents(); + _collection.Add(_control); + _canvas.Children.Add(_control); + return string.Empty; + } +} + +public class MarkupStrokeAddedItem : UndoRedoItem +{ + private readonly InkCanvas _canvas; + private readonly Stroke _stroke; + + public MarkupStrokeAddedItem(InkCanvas canvas, Stroke stroke) + { + _canvas = canvas; + _stroke = stroke; + } + + public override string Undo() + { + _canvas.Strokes.Remove(_stroke); + return string.Empty; + } + + public override string Redo() + { + if (!_canvas.Strokes.Contains(_stroke)) + _canvas.Strokes.Add(_stroke); + return string.Empty; + } +} + +public class MarkupStrokeBatchAddedItem : UndoRedoItem +{ + private readonly InkCanvas _canvas; + private readonly List _strokes; + + public MarkupStrokeBatchAddedItem(InkCanvas canvas, List strokes) + { + _canvas = canvas; + _strokes = strokes; + } + + public override string Undo() + { + foreach (Stroke stroke in _strokes) + _canvas.Strokes.Remove(stroke); + return string.Empty; + } + + public override string Redo() + { + foreach (Stroke stroke in _strokes) + if (!_canvas.Strokes.Contains(stroke)) + _canvas.Strokes.Add(stroke); + return string.Empty; + } +} + +public class MarkupStrokeMovedItem : UndoRedoItem +{ + private readonly StrokeCollection _strokes; + private readonly double _deltaX; + private readonly double _deltaY; + + public MarkupStrokeMovedItem(StrokeCollection strokes, double deltaX, double deltaY) + { + _strokes = new StrokeCollection(strokes); + _deltaX = deltaX; + _deltaY = deltaY; + } + + public override string Undo() + { + Matrix m = new(); + m.Translate(-_deltaX, -_deltaY); + foreach (Stroke s in _strokes) + s.Transform(m, false); + return string.Empty; + } + + public override string Redo() + { + Matrix m = new(); + m.Translate(_deltaX, _deltaY); + foreach (Stroke s in _strokes) + s.Transform(m, false); + return string.Empty; + } +} + +public class MarkupStrokeDeletedItem : UndoRedoItem +{ + private readonly InkCanvas _canvas; + private readonly List _strokes; + + public MarkupStrokeDeletedItem(InkCanvas canvas, StrokeCollection strokes) + { + _canvas = canvas; + _strokes = [.. strokes]; + } + + public override string Undo() + { + foreach (Stroke s in _strokes) + if (!_canvas.Strokes.Contains(s)) + _canvas.Strokes.Add(s); + return string.Empty; + } + + public override string Redo() + { + foreach (Stroke s in _strokes) + _canvas.Strokes.Remove(s); + return string.Empty; + } +} + +public class MarkupStrokePropertiesChangedItem : UndoRedoItem +{ + private readonly List<(Stroke stroke, DrawingAttributes before, DrawingAttributes after)> _changes; + + public MarkupStrokePropertiesChangedItem(List<(Stroke, DrawingAttributes, DrawingAttributes)> changes) + { + _changes = changes; + } + + public override string Undo() + { + foreach (var (stroke, before, _) in _changes) + stroke.DrawingAttributes = before; + return string.Empty; + } + + public override string Redo() + { + foreach (var (stroke, _, after) in _changes) + stroke.DrawingAttributes = after; + return string.Empty; + } +} + +public class MarkupControlRemovedItem : UndoRedoItem where T : UIElement +{ + private readonly T _control; + private readonly ObservableCollection _collection; + private readonly Canvas _canvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupControlRemovedItem( + T control, + ObservableCollection collection, + Canvas canvas, + Action wireEvents, + Action unwireEvents) + { + _control = control; + _collection = collection; + _canvas = canvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _wireEvents(); + _collection.Add(_control); + _canvas.Children.Add(_control); + return string.Empty; + } + + public override string Redo() + { + _unwireEvents(); + _collection.Remove(_control); + _canvas.Children.Remove(_control); + return string.Empty; + } +} + +public class MarkupTextMovedItem : UndoRedoItem +{ + private readonly MarkupTextControl _control; + private readonly Point _before; + private readonly Point _after; + + public MarkupTextMovedItem(MarkupTextControl control, Point before, Point after) + { + _control = control; + _before = before; + _after = after; + } + + public override string Undo() + { + Canvas.SetLeft(_control, _before.X); + Canvas.SetTop(_control, _before.Y); + return string.Empty; + } + + public override string Redo() + { + Canvas.SetLeft(_control, _after.X); + Canvas.SetTop(_control, _after.Y); + return string.Empty; + } +} + +public class MarkupTextChangedItem : UndoRedoItem +{ + private readonly MarkupTextControl _control; + private readonly string _before; + private readonly string _after; + + public MarkupTextChangedItem(MarkupTextControl control, string before, string after) + { + _control = control; + _before = before; + _after = after; + } + + public override string Undo() + { + _control.MarkupText = _before; + return string.Empty; + } + + public override string Redo() + { + _control.MarkupText = _after; + return string.Empty; + } +} + +public class MarkupStrokeResizedItem : UndoRedoItem +{ + private readonly StrokeCollection _strokes; + private readonly Rect _before; + private readonly Rect _after; + + public MarkupStrokeResizedItem(StrokeCollection strokes, Rect before, Rect after) + { + _strokes = new StrokeCollection(strokes); + _before = before; + _after = after; + } + + public override string Undo() + { + Apply(_after, _before); + return string.Empty; + } + + public override string Redo() + { + Apply(_before, _after); + return string.Empty; + } + + private void Apply(Rect from, Rect to) + { + Matrix m = new(); + m.Translate(-from.X, -from.Y); + m.Scale(to.Width / from.Width, to.Height / from.Height); + m.Translate(to.X, to.Y); + foreach (Stroke s in _strokes) + s.Transform(m, false); + } +} + +public class MarkupClearedItem : UndoRedoItem +{ + private readonly List _shapes; + private readonly List _texts; + private readonly List _strokes; + private readonly ObservableCollection _shapeCollection; + private readonly ObservableCollection _textCollection; + private readonly Canvas _canvas; + private readonly InkCanvas _inkCanvas; + private readonly Action _wireEvents; + private readonly Action _unwireEvents; + + public MarkupClearedItem( + List shapes, + List texts, + List strokes, + ObservableCollection shapeCollection, + ObservableCollection textCollection, + Canvas canvas, + InkCanvas inkCanvas, + Action wireEvents, + Action unwireEvents) + { + _shapes = shapes; + _texts = texts; + _strokes = strokes; + _shapeCollection = shapeCollection; + _textCollection = textCollection; + _canvas = canvas; + _inkCanvas = inkCanvas; + _wireEvents = wireEvents; + _unwireEvents = unwireEvents; + } + + public override string Undo() + { + _wireEvents(); + foreach (MarkupShapeControl shape in _shapes) + { + _shapeCollection.Add(shape); + _canvas.Children.Add(shape); + } + foreach (MarkupTextControl text in _texts) + { + _textCollection.Add(text); + _canvas.Children.Add(text); + } + foreach (Stroke stroke in _strokes) + if (!_inkCanvas.Strokes.Contains(stroke)) + _inkCanvas.Strokes.Add(stroke); + return string.Empty; + } + + public override string Redo() + { + _unwireEvents(); + foreach (MarkupShapeControl shape in _shapes) + { + _shapeCollection.Remove(shape); + _canvas.Children.Remove(shape); + } + foreach (MarkupTextControl text in _texts) + { + _textCollection.Remove(text); + _canvas.Children.Remove(text); + } + foreach (Stroke stroke in _strokes) + _inkCanvas.Strokes.Remove(stroke); + return string.Empty; + } +} + public class ResizeUndoRedoItem : UndoRedoItem { private readonly Image _image; diff --git a/MagickCrop/ViewModels/MainWindowViewModel.cs b/MagickCrop/ViewModels/MainWindowViewModel.cs index 3f88e50..1bf78ce 100644 --- a/MagickCrop/ViewModels/MainWindowViewModel.cs +++ b/MagickCrop/ViewModels/MainWindowViewModel.cs @@ -49,10 +49,12 @@ public void SetView(IMainWindowView view) [NotifyCanExecuteChangedFor(nameof(ApplyAutoGammaCommand))] [NotifyCanExecuteChangedFor(nameof(ApplyBlurCommand))] [NotifyCanExecuteChangedFor(nameof(ApplyFindEdgesCommand))] + [NotifyCanExecuteChangedFor(nameof(ApplyDespeckleCommand))] [NotifyCanExecuteChangedFor(nameof(Rotate90CwCommand))] [NotifyCanExecuteChangedFor(nameof(Rotate90CcwCommand))] [NotifyCanExecuteChangedFor(nameof(FlipVerticalCommand))] [NotifyCanExecuteChangedFor(nameof(FlipHorizontalCommand))] + [NotifyCanExecuteChangedFor(nameof(ApplyThresholdCommand))] private string? imagePath; [ObservableProperty] @@ -273,6 +275,15 @@ private async Task Share() [RelayCommand(CanExecute = nameof(CanApplyAdjustment))] private Task ApplyFindEdges() => ApplyAdjustmentAsync(img => img.CannyEdge()); + [RelayCommand(CanExecute = nameof(CanApplyAdjustment))] + private Task ApplyDespeckle() => ApplyAdjustmentAsync(img => img.Despeckle()); + + [ObservableProperty] + private double thresholdValue = 128.0; + + [RelayCommand(CanExecute = nameof(CanApplyAdjustment))] + private Task ApplyThreshold() => ApplyAdjustmentAsync(img => img.Threshold(new Percentage(ThresholdValue / 255.0 * 100.0))); + private async Task ApplyAdjustmentAsync(Action adjustment) { if (_view is null || string.IsNullOrWhiteSpace(ImagePath))