using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Shapes; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Layout; using Avalonia.Skia.Helpers; using CommunityToolkit.Mvvm.Input; using FluentResults; using InABox.Avalonia.Components.ImageEditing; using InABox.Avalonia.Converters; using InABox.Core; using SkiaSharp; using System.Collections.ObjectModel; using System.Threading.Tasks; using Avalonia.LogicalTree; namespace InABox.Avalonia.Components; public enum ImageEditingMode { Polyline, Rectangle, Ellipse, Text, Dimension } public class ImageEditorModeButton(ImageEditingMode mode, Control? content) { public ImageEditingMode Mode { get; set; } = mode; public Control? Content { get; set; } = content; } public class ImageEditorTransparentImageBrushConverter : AbstractConverter { public static readonly ImageEditorTransparentImageBrushConverter Instance = new ImageEditorTransparentImageBrushConverter(); protected override IBrush? Convert(IBrush? value, object? parameter = null) { if (value is SolidColorBrush solid && solid.Color.A == 255) return solid; var brush = new VisualBrush { TileMode = TileMode.Tile, DestinationRect = new(0, 0, 10, 10, RelativeUnit.Absolute) }; var canvas = new Canvas { Width = 10, Height = 10 }; var rect1 = new Rectangle { Width = 5, Height = 5 }; var rect2 = new Rectangle { Width = 5, Height = 5 }; Canvas.SetLeft(rect2, 5); Canvas.SetTop(rect2, 5); rect1.Fill = new SolidColorBrush(Colors.LightGray); rect2.Fill = new SolidColorBrush(Colors.LightGray); var rect3 = new Rectangle { Width = 10, Height = 10 }; rect3.Fill = value; canvas.Children.Add(rect1); canvas.Children.Add(rect2); canvas.Children.Add(rect3); brush.Visual = canvas; return brush; } } public class ImageEditorRemoveOpacityConverter : AbstractConverter { public static readonly ImageEditorRemoveOpacityConverter Instance = new(); protected override IBrush? Convert(IBrush? value, object? parameter = null) { if (value is SolidColorBrush solid) { return new SolidColorBrush(new Color(255, solid.Color.R, solid.Color.G, solid.Color.B)); } return value; } } // TODO: Make it so we don't re-render everything everytime 'Objects' changes. public partial class ImageEditor : UserControl { public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register(nameof(Source)); public static readonly StyledProperty PrimaryBrushProperty = AvaloniaProperty.Register(nameof(PrimaryBrush), new SolidColorBrush(Colors.Black)); public static readonly StyledProperty SecondaryBrushProperty = AvaloniaProperty.Register(nameof(SecondaryBrush), new SolidColorBrush(Colors.White)); public static readonly StyledProperty LineThicknessProperty = AvaloniaProperty.Register(nameof(LineThickness), 3.0); public static readonly StyledProperty ImageWidthProperty = AvaloniaProperty.Register(nameof(ImageWidth), 100); public static readonly StyledProperty ImageHeightProperty = AvaloniaProperty.Register(nameof(ImageHeight), 100); public static readonly StyledProperty ModeProperty = AvaloniaProperty.Register(nameof(Mode), ImageEditingMode.Polyline); public static readonly StyledProperty ShowButtonsProperty = AvaloniaProperty.Register(nameof(ShowButtons), true); public IImage? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } public int ImageWidth { get => GetValue(ImageWidthProperty); set => SetValue(ImageWidthProperty, value); } public int ImageHeight { get => GetValue(ImageHeightProperty); set => SetValue(ImageHeightProperty, value); } public bool ShowButtons { get => GetValue(ShowButtonsProperty); set => SetValue(ShowButtonsProperty, value); } #region Editing Properties public ImageEditingMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); } public IBrush? PrimaryBrush { get => GetValue(PrimaryBrushProperty); set => SetValue(PrimaryBrushProperty, value); } public IBrush? SecondaryBrush { get => GetValue(SecondaryBrushProperty); set => SetValue(SecondaryBrushProperty, value); } public double LineThickness { get => GetValue(LineThicknessProperty); set => SetValue(LineThicknessProperty, value); } #endregion #region Events public event EventHandler? Changed; #endregion #region Private Properties public ObservableCollection ModeButtons { get; set; } = new(); private ObservableCollection Objects = new(); private IImageEditorObject? _currentObject; private IImageEditorObject? CurrentObject { get => _currentObject; set { _currentObject?.SetActive(false); _currentObject = value; } } private Stack RedoStack = new(); private double ScaleFactor = 1.0; #endregion static ImageEditor() { SourceProperty.Changed.AddClassHandler(Source_Changed); } private static void Source_Changed(ImageEditor editor, AvaloniaPropertyChangedEventArgs args) { if(editor.Source is not null) { editor.ImageWidth = (int)Math.Floor(editor.Source.Size.Width); editor.ImageHeight = (int)Math.Floor(editor.Source.Size.Height); editor.PositionImage(); } } public ImageEditor() { InitializeComponent(); Objects.CollectionChanged += Objects_CollectionChanged; AddModeButtons(); SetMode(Mode); OuterCanvas.LayoutUpdated += OuterCanvas_LayoutUpdated; } #region Layout private void OuterCanvas_LayoutUpdated(object? sender, EventArgs e) { PositionImage(); } protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); PositionImage(); } private void PositionImage() { var canvasWidth = OuterCanvas.Bounds.Width; var canvasHeight = OuterCanvas.Bounds.Height; var scaleFactor = Math.Min(canvasWidth / ImageWidth, canvasHeight / ImageHeight); ScaleFactor = scaleFactor; var imageWidth = ImageWidth * scaleFactor; var imageHeight = ImageHeight * scaleFactor; ImageBorder.Width = imageWidth; ImageBorder.Height = imageHeight; Canvas.SetLeft(ImageBorder, OuterCanvas.Bounds.Width / 2 - imageWidth / 2); Canvas.SetTop(ImageBorder, OuterCanvas.Bounds.Height / 2 - imageHeight / 2); Canvas.RenderTransform = new ScaleTransform(ScaleFactor, ScaleFactor); Canvas.Width = ImageWidth; Canvas.Height = ImageHeight; } #endregion #region Editing Commands private void UpdateUndoRedoButtons() { UndoButton.IsEnabled = Objects.Count > 0; RedoButton.IsEnabled = RedoStack.Count > 0; } [RelayCommand] private void Undo() { if (Objects.Count == 0) return; RedoStack.Push(Objects[^1]); Objects.RemoveAt(Objects.Count - 1); UpdateUndoRedoButtons(); Changed?.Invoke(this, new EventArgs()); } [RelayCommand] private void Redo() { if (!RedoStack.TryPop(out var top)) return; Objects.Add(top); UpdateUndoRedoButtons(); Changed?.Invoke(this, new EventArgs()); } private void AddObject(IImageEditorObject obj) { Objects.Add(obj); RedoStack.Clear(); UpdateUndoRedoButtons(); Changed?.Invoke(this, new EventArgs()); } [RelayCommand] private void SetMode(ImageEditingMode mode) { Mode = mode; ShapeButton.Content = CreateModeButtonContent(mode); SecondaryColour.IsVisible = HasSecondaryColour(); LineThicknessButton.IsVisible = HasLineThickness(); } private bool HasSecondaryColour() { return Mode == ImageEditingMode.Rectangle || Mode == ImageEditingMode.Ellipse; } private bool HasLineThickness() { return Mode == ImageEditingMode.Rectangle || Mode == ImageEditingMode.Ellipse || Mode == ImageEditingMode.Polyline || Mode == ImageEditingMode.Dimension; } #endregion #region Mode Buttons private void AddModeButtons() { AddModeButton(ImageEditingMode.Polyline); AddModeButton(ImageEditingMode.Rectangle); AddModeButton(ImageEditingMode.Ellipse); AddModeButton(ImageEditingMode.Text); AddModeButton(ImageEditingMode.Dimension); } private void AddModeButton(ImageEditingMode mode) { ModeButtons.Add(new(mode, CreateModeButtonContent(mode))); } private Control? CreateModeButtonContent(ImageEditingMode mode, bool bindColour = false) { switch (mode) { case ImageEditingMode.Polyline: var canvas = new Canvas(); { var points = new Point[] { new(0, 0), new(20, 8), new(5, 16), new(25, 25) }; var line1 = new Polyline { Points = points, Width = 25, Height = 25 }; var line2 = new Polyline { Points = points, Width = 25, Height = 25 }; line1.StrokeThickness = 4; line1.StrokeLineCap = PenLineCap.Round; line1.StrokeJoin = PenLineJoin.Round; line1.Stroke = new SolidColorBrush(Colors.Black); canvas.Children.Add(line1); if (bindColour) { line1.StrokeThickness = 5; line2.StrokeThickness = 4; line2.StrokeLineCap = PenLineCap.Round; line2.StrokeJoin = PenLineJoin.Round; line2.Bind(Polyline.StrokeProperty, new Binding(nameof(PrimaryBrush)) { Source = this, Converter = ImageEditorRemoveOpacityConverter.Instance }); canvas.Children.Add(line2); } } return canvas; case ImageEditingMode.Rectangle: canvas = new Canvas(); canvas.Width = 25; canvas.Height = 25; var rectangle = new Rectangle(); if (bindColour) { rectangle.Bind(Rectangle.StrokeProperty, new Binding(nameof(PrimaryBrush)) { Source = this, Converter = ImageEditorRemoveOpacityConverter.Instance }); rectangle.Bind(Rectangle.FillProperty, new Binding(nameof(SecondaryBrush)) { Source = this, Converter = ImageEditorTransparentImageBrushConverter.Instance }); } else { rectangle.Stroke = new SolidColorBrush(Colors.Black); rectangle.Fill = new SolidColorBrush(Colors.White); } rectangle.StrokeThickness = 1.0; rectangle.Width = 25; rectangle.Height = 25; canvas.Children.Add(rectangle); return canvas; case ImageEditingMode.Ellipse: canvas = new Canvas(); canvas.Width = 25; canvas.Height = 25; var ellipse = new Ellipse(); if (bindColour) { ellipse.Bind(Rectangle.StrokeProperty, new Binding(nameof(PrimaryBrush)) { Source = this, Converter = ImageEditorRemoveOpacityConverter.Instance }); ellipse.Bind(Rectangle.FillProperty, new Binding(nameof(SecondaryBrush)) { Source = this, Converter = ImageEditorTransparentImageBrushConverter.Instance }); } else { ellipse.Stroke = new SolidColorBrush(Colors.Black); ellipse.Fill = new SolidColorBrush(Colors.White); } ellipse.StrokeThickness = 1.0; ellipse.Width = 25; ellipse.Height = 25; canvas.Children.Add(ellipse); return canvas; case ImageEditingMode.Text: var textBox = new TextBlock(); textBox.Text = "T"; textBox.FontSize = 25; textBox.TextAlignment = TextAlignment.Center; textBox.HorizontalAlignment = HorizontalAlignment.Center; textBox.VerticalAlignment = VerticalAlignment.Center; if (bindColour) { textBox.Bind(TextBlock.ForegroundProperty, new Binding(nameof(PrimaryBrush)) { Source = this, Converter = ImageEditorRemoveOpacityConverter.Instance }); } return textBox; case ImageEditingMode.Dimension: canvas = new Canvas(); canvas.Width = 25; canvas.Height = 25; { var dimLines = new List(); dimLines.Add(new Line { StartPoint = new(2, 10), EndPoint = new(23, 10), StrokeLineCap = PenLineCap.Round }); dimLines.Add(new Line { StartPoint = new(2, 10), EndPoint = new(5, 7), StrokeLineCap = PenLineCap.Square }); dimLines.Add(new Line { StartPoint = new(2, 10), EndPoint = new(5, 13), StrokeLineCap = PenLineCap.Square }); dimLines.Add(new Line { StartPoint = new(23, 10), EndPoint = new(20, 7), StrokeLineCap = PenLineCap.Square }); dimLines.Add(new Line { StartPoint = new(23, 10), EndPoint = new(20, 13), StrokeLineCap = PenLineCap.Square }); var dotLines = new List(); dotLines.Add(new Line { StartPoint = new(2, 10), EndPoint = new(2, 24), StrokeDashArray = [2, 2] }); dotLines.Add(new Line { StartPoint = new(23, 10), EndPoint = new(23, 24), StrokeDashArray = [2, 2] }); var number = new TextBlock { Text = "10", FontSize = 9, TextAlignment = TextAlignment.Center, Width = 25 }; Canvas.SetLeft(number, 0); Canvas.SetTop(number, -1); foreach (var line in dimLines) { line.StrokeThickness = 2; line.Stroke = new SolidColorBrush(Colors.Black); } foreach (var line in dotLines) { line.StrokeThickness = 1; line.Stroke = new SolidColorBrush(Colors.Black); } if (bindColour) { foreach (var line in dimLines) { line.Bind(Polyline.StrokeProperty, new Binding(nameof(PrimaryBrush)) { Source = this, Converter = ImageEditorRemoveOpacityConverter.Instance }); } foreach (var line in dotLines) { line.Bind(Polyline.StrokeProperty, new Binding(nameof(PrimaryBrush)) { Source = this, Converter = ImageEditorRemoveOpacityConverter.Instance }); } } foreach (var line in dimLines) { canvas.Children.Add(line); } foreach (var line in dotLines) { canvas.Children.Add(line); } canvas.Children.Add(number); } return canvas; default: return null; } } #endregion #region Public Interface public void Reset() { Objects.Clear(); RedoStack.Clear(); UpdateUndoRedoButtons(); Changed?.Invoke(this, new EventArgs()); } public Bitmap GetImage() { var renderBitmap = new RenderTargetBitmap(new PixelSize(ImageWidth, ImageHeight)); renderBitmap.Render(Image); using var context = renderBitmap.CreateDrawingContext(); if(Source is not null) { context.DrawImage(Source, new(0, 0, ImageWidth, ImageHeight)); } CurrentObject = null; foreach (var obj in Objects) { var control = obj.GetControl(); Render(context, control); } return renderBitmap; } private void Render(DrawingContext context, Control control) { var left = Canvas.GetLeft(control); var top = Canvas.GetTop(control); if (double.IsNaN(left)) left = 0; if (double.IsNaN(top)) top = 0; var matrix = Matrix.CreateTranslation(new(left, top)); if(control.RenderTransform is not null) { Vector offset; if(control.RenderTransformOrigin.Unit == RelativeUnit.Relative) { offset = new Vector( control.Bounds.Width * control.RenderTransformOrigin.Point.X, control.Bounds.Height * control.RenderTransformOrigin.Point.Y); } else { offset = new Vector(control.RenderTransformOrigin.Point.X, control.RenderTransformOrigin.Point.Y); } matrix = (Matrix.CreateTranslation(-offset) * control.RenderTransform.Value * Matrix.CreateTranslation(offset)) * matrix; } using (context.PushTransform(matrix)) { control.Render(context); if(control is Panel panel) { foreach(var child in panel.Children) { Render(context, child); } } } } public byte[] SaveImage() { var bitmap = GetImage(); var stream = new MemoryStream(); bitmap.Save(stream); return stream.ToArray(); } #endregion #region Editing private void RefreshObjects() { Canvas.Children.Clear(); foreach(var item in Objects) { item.Update(); Canvas.Children.Add(item.GetControl()); } } private void Objects_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { RefreshObjects(); } Point ConvertToImageCoordinates(Point canvasCoordinates) { return canvasCoordinates;// new(canvasCoordinates.X / ScaleFactor, canvasCoordinates.Y / ScaleFactor); } private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) { CurrentObject = null; var position = ConvertToImageCoordinates(e.GetPosition(Canvas)); switch (Mode) { case ImageEditingMode.Polyline: CurrentObject = new PolylineObject { Points = [position], PrimaryBrush = PrimaryBrush, Thickness = LineThickness }; AddObject(CurrentObject); break; case ImageEditingMode.Rectangle: CurrentObject = new RectangleObject { Point1 = position, Point2 = position, PrimaryBrush = PrimaryBrush, SecondaryBrush = SecondaryBrush, Thickness = LineThickness }; AddObject(CurrentObject); break; case ImageEditingMode.Ellipse: CurrentObject = new EllipseObject { Point1 = position, Point2 = position, PrimaryBrush = PrimaryBrush, SecondaryBrush = SecondaryBrush, Thickness = LineThickness }; AddObject(CurrentObject); break; case ImageEditingMode.Text: CurrentObject = new SelectionObject { Point1 = position, Point2 = position, PrimaryBrush = PrimaryBrush }; AddObject(CurrentObject); break; case ImageEditingMode.Dimension: CurrentObject = new DimensionObject { Point1 = position, Point2 = position, PrimaryBrush = PrimaryBrush, Text = "", Offset = 30, LineThickness = LineThickness }; AddObject(CurrentObject); break; } } private void Canvas_PointerMoved(object? sender, PointerEventArgs e) { var position = ConvertToImageCoordinates(e.GetPosition(Canvas)); switch (CurrentObject) { case PolylineObject polyline: polyline.Points.Add(position); polyline.Update(); Changed?.Invoke(this, new EventArgs()); break; case RectangleObject rectangle: rectangle.Point2 = position; rectangle.Update(); Changed?.Invoke(this, new EventArgs()); break; case EllipseObject ellipse: ellipse.Point2 = position; ellipse.Update(); Changed?.Invoke(this, new EventArgs()); break; case SelectionObject textSelection: textSelection.Point2 = position; textSelection.Update(); Changed?.Invoke(this, new EventArgs()); break; case DimensionObject dimension: if (!dimension.Complete) { dimension.Point2 = position; dimension.Update(); Changed?.Invoke(this, new EventArgs()); } break; } } private void Canvas_PointerReleased(object? sender, PointerReleasedEventArgs e) { var position = ConvertToImageCoordinates(e.GetPosition(Canvas)); switch (CurrentObject) { case PolylineObject polyline: polyline.Points.Add(position); polyline.Update(); CurrentObject = null; Changed?.Invoke(this, new EventArgs()); break; case RectangleObject rectangle: rectangle.Point2 = position; rectangle.Update(); CurrentObject = null; Changed?.Invoke(this, new EventArgs()); break; case EllipseObject ellipse: ellipse.Point2 = position; ellipse.Update(); CurrentObject = null; Changed?.Invoke(this, new EventArgs()); break; case SelectionObject selection: selection.Point2 = position; Objects.Remove(selection); CurrentObject = null; CreateObjectFromSelection(selection).ContinueWith(task => { if(task.Exception != null) { MobileLogging.LogExceptionMessage(task.Exception); } }); break; case DimensionObject dimension: dimension.Point2 = position; if(dimension.Point1 == dimension.Point2) { Objects.Remove(dimension); CurrentObject = null; return; } dimension.Complete = true; Navigation.Popup(x => { }).ContinueWith(task => { dimension.Text = task.Result ?? ""; dimension.Update(); }, TaskScheduler.FromCurrentSynchronizationContext()); Changed?.Invoke(this, new EventArgs()); break; } } private async Task CreateObjectFromSelection(SelectionObject selection) { switch (Mode) { case ImageEditingMode.Text: var text = await Navigation.Popup(x => { }); if(text is null) { return; } CurrentObject = new TextObject { Point = selection.GetTopLeft(), Size = selection.GetSize(), Text = text, PrimaryBrush = selection.PrimaryBrush }; AddObject(CurrentObject); break; } } #endregion }