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.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; namespace InABox.Avalonia.Components; public enum ImageEditingMode { Polyline, Rectangle, Ellipse } public class ImageEditorModeButton(ImageEditingMode mode, Canvas? canvas) { public ImageEditingMode Mode { get; set; } = mode; public Canvas? Canvas { get; set; } = canvas; } 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 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(); } private bool HasSecondaryColour() { return Mode == ImageEditingMode.Rectangle || Mode == ImageEditingMode.Ellipse; } #endregion #region Mode Buttons private void AddModeButtons() { AddModeButton(ImageEditingMode.Polyline); AddModeButton(ImageEditingMode.Rectangle); AddModeButton(ImageEditingMode.Ellipse); } private void AddModeButton(ImageEditingMode mode) { ModeButtons.Add(new(mode, CreateModeButtonContent(mode))); } private Canvas? 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; 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)); } foreach (var obj in Objects) { var control = obj.GetControl(); var left = Canvas.GetLeft(control); var top = Canvas.GetTop(control); if (double.IsNaN(left)) left = 0; if (double.IsNaN(top)) top = 0; using (context.PushTransform(Matrix.CreateTranslation(new(left, top)))) { control.Render(context); } } return renderBitmap; } public byte[] SaveImage() { var bitmap = GetImage(); var stream = new MemoryStream(); bitmap.Save(stream); return stream.ToArray(); } #endregion #region Editing private void Objects_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { Canvas.Children.Clear(); foreach(var item in Objects) { item.Update(); Canvas.Children.Add(item.GetControl()); } } Point ConvertToImageCoordinates(Point canvasCoordinates) { return canvasCoordinates;// new(canvasCoordinates.X / ScaleFactor, canvasCoordinates.Y / ScaleFactor); } private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) { 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; } } 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; } } 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; } } #endregion }