using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; using Avalonia.Media.Imaging; using LehrerApp.Templating; namespace LehrerApp.TemplateDesigner; public enum OverlayEditorMode { Measure, Edit } public sealed record OverlayMeasurement(double X, double Y, double Width, double Height, bool HasArea); public sealed record OverlayElementGeometry(int Line, string Keyword, double X, double Y, double Width, double Height); public sealed class LayoutOverlayEditor : Control { public static readonly StyledProperty PreviewImageProperty = AvaloniaProperty.Register(nameof(PreviewImage)); public static readonly StyledProperty LayoutSourceProperty = AvaloniaProperty.Register(nameof(LayoutSource), ""); public static readonly StyledProperty PageWidthProperty = AvaloniaProperty.Register(nameof(PageWidth), 210); public static readonly StyledProperty PageHeightProperty = AvaloniaProperty.Register(nameof(PageHeight), 297); public static readonly StyledProperty PageUnitProperty = AvaloniaProperty.Register(nameof(PageUnit), "mm"); public static readonly StyledProperty ModeProperty = AvaloniaProperty.Register(nameof(Mode), OverlayEditorMode.Measure); public static readonly StyledProperty SnapToGridProperty = AvaloniaProperty.Register(nameof(SnapToGrid), true); public static readonly StyledProperty GridSizeProperty = AvaloniaProperty.Register(nameof(GridSize), 1); private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5); private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5); private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2); private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1); private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235)); private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38)); private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626")); private readonly List _items = []; private OverlayItem? _selected; private Point? _pointerStartDsl; private Rect? _workingRectDsl; private DragKind _dragKind; public Bitmap? PreviewImage { get => GetValue(PreviewImageProperty); set => SetValue(PreviewImageProperty, value); } public string LayoutSource { get => GetValue(LayoutSourceProperty); set => SetValue(LayoutSourceProperty, value); } public double PageWidth { get => GetValue(PageWidthProperty); set => SetValue(PageWidthProperty, value); } public double PageHeight { get => GetValue(PageHeightProperty); set => SetValue(PageHeightProperty, value); } public string PageUnit { get => GetValue(PageUnitProperty); set => SetValue(PageUnitProperty, value); } public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); } public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); } public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); } public event EventHandler? MeasurementCompleted; public event EventHandler? ElementGeometryChanged; public event EventHandler? CursorCoordinatesChanged; public event EventHandler? ElementSelected; static LayoutOverlayEditor() { AffectsRender(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty, PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty); } public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; } public override void Render(DrawingContext context) { base.Render(context); var page = PageRect(); context.FillRectangle(Brushes.White, page); if (PreviewImage is { } image) context.DrawImage(image, new Rect(image.Size), page); DrawGrid(context, page); RefreshItems(); foreach (var item in _items) { var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect; var rect = ToControl(geometry, page); var selected = ReferenceEquals(item, _selected); context.DrawRectangle(selected ? _selectedFill : _normalFill, selected ? _selectedPen : _normalPen, rect, 2, 2); if (selected && item.Resizable) context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2); } if (Mode == OverlayEditorMode.Measure && _workingRectDsl is { } measurement) context.DrawRectangle(null, _measurePen, ToControl(measurement, page), 2, 2); } protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); var point = e.GetPosition(this); if (!PageRect().Contains(point)) return; Focus(); e.Pointer.Capture(this); var dsl = Snap(ToDsl(point)); _pointerStartDsl = dsl; if (Mode == OverlayEditorMode.Measure) { _selected = null; _dragKind = DragKind.Measure; _workingRectDsl = new Rect(dsl, dsl); InvalidateVisual(); return; } RefreshItems(); _selected = _items.LastOrDefault(item => ToControl(item.Rect, PageRect()).Inflate(4).Contains(point)); if (_selected is null) { _workingRectDsl = null; InvalidateVisual(); return; } _workingRectDsl = _selected.Rect; var selectedControl = ToControl(_selected.Rect, PageRect()); _dragKind = _selected.Resizable && new Point(selectedControl.Right, selectedControl.Bottom).Distance(point) <= 14 ? DragKind.Resize : DragKind.Move; ElementSelected?.Invoke(this, $"{_selected.Keyword} · Zeile {_selected.Line}"); InvalidateVisual(); } protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); var point = e.GetPosition(this); var page = PageRect(); if (page.Contains(point)) { var coordinate = ToDsl(point); CursorCoordinatesChanged?.Invoke(this, $"x={coordinate.X:0.##} · y={coordinate.Y:0.##} {PageUnit}"); } if (_pointerStartDsl is not { } start || _dragKind == DragKind.None) return; var current = Snap(ToDsl(Clamp(point, page))); if (_dragKind == DragKind.Measure) _workingRectDsl = Normalize(start, current); else if (_selected is not null) { var source = _selected.Rect; if (_dragKind == DragKind.Move) { var x = Math.Clamp(source.X + current.X - start.X, 0, Math.Max(0, PageWidth - source.Width)); var y = Math.Clamp(source.Y + current.Y - start.Y, 0, Math.Max(0, PageHeight - source.Height)); _workingRectDsl = new Rect(x, y, source.Width, source.Height); } else { var width = Math.Max(GridStep(), source.Width + current.X - start.X); var height = Math.Max(GridStep(), source.Height + current.Y - start.Y); _workingRectDsl = new Rect(source.X, source.Y, Math.Min(width, PageWidth - source.X), Math.Min(height, PageHeight - source.Y)); } } InvalidateVisual(); } protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); e.Pointer.Capture(null); if (_workingRectDsl is { } result) { if (_dragKind == DragKind.Measure) { var hasArea = result.Width >= GridStep() / 2 && result.Height >= GridStep() / 2; MeasurementCompleted?.Invoke(this, new(result.X, result.Y, result.Width, result.Height, hasArea)); } else if (_selected is not null) ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword, result.X, result.Y, result.Width, result.Height)); } _pointerStartDsl = null; _dragKind = DragKind.None; if (Mode == OverlayEditorMode.Edit) _workingRectDsl = null; InvalidateVisual(); } protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (Mode != OverlayEditorMode.Edit || _selected is null) return; var step = GridStep(); var dx = 0d; var dy = 0d; switch (e.Key) { case Key.Left: dx = -step; break; case Key.Right: dx = step; break; case Key.Up: dy = -step; break; case Key.Down: dy = step; break; default: return; } var source = _selected.Rect; var rect = new Rect(Math.Clamp(source.X + dx, 0, Math.Max(0, PageWidth - source.Width)), Math.Clamp(source.Y + dy, 0, Math.Max(0, PageHeight - source.Height)), source.Width, source.Height); ElementGeometryChanged?.Invoke(this, new(_selected.Line, _selected.Keyword, rect.X, rect.Y, rect.Width, rect.Height)); e.Handled = true; } private void RefreshItems() { _items.Clear(); try { var layout = new LayoutParser().Parse(LayoutSource); foreach (var element in layout.Elements) { if (element is BackgroundElement) continue; var keyword = element switch { ImageElement => "IMG", TextElement => "TEXT", TextBoxElement => "TEXTBOX", TableElement => "TABLE", ChartElement => "CHART", _ => "?" }; var (width, height, resizable) = element switch { TextElement text => (Math.Min(70d, Math.Max(5, PageWidth - text.X)), Math.Max(3, PointsToUnit(ParseSize(text.Attributes) * 1.8)), false), ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true), _ => ((double)element.Width, element.Height, true), }; _items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable)); } if (_selected is not null) _selected = _items.FirstOrDefault(x => x.Line == _selected.Line); } catch (TemplateValidationException) { _selected = null; } } private void DrawGrid(DrawingContext context, Rect page) { if (!SnapToGrid || GridSize <= 0) return; var xStep = page.Width * GridSize / PageWidth; var yStep = page.Height * GridSize / PageHeight; if (xStep < 5 || yStep < 5) return; for (var x = page.X + xStep; x < page.Right; x += xStep) context.DrawLine(_gridPen, new(x, page.Y), new(x, page.Bottom)); for (var y = page.Y + yStep; y < page.Bottom; y += yStep) context.DrawLine(_gridPen, new(page.X, y), new(page.Right, y)); } private Rect PageRect() { return OverlayCoordinateMapper.PageRect(Bounds.Size, PageWidth, PageHeight); } private Rect ToControl(Rect rect, Rect page) => OverlayCoordinateMapper.ToControl(rect, page, PageWidth, PageHeight); private Point ToDsl(Point point) => OverlayCoordinateMapper.ToDsl(point, PageRect(), PageWidth, PageHeight); private Point Snap(Point point) { if (!SnapToGrid) return point; var step = GridStep(); return new(Math.Round(point.X / step) * step, Math.Round(point.Y / step) * step); } private double GridStep() => SnapToGrid && GridSize > 0 ? GridSize : 0.5; private static Rect Normalize(Point first, Point second) => new(Math.Min(first.X, second.X), Math.Min(first.Y, second.Y), Math.Abs(second.X - first.X), Math.Abs(second.Y - first.Y)); private static Point Clamp(Point point, Rect rect) => new(Math.Clamp(point.X, rect.X, rect.Right), Math.Clamp(point.Y, rect.Y, rect.Bottom)); private static float ParseSize(IReadOnlyDictionary attributes) => attributes.TryGetValue("size", out var raw) && float.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var size) ? size : 11; private static float ImageScale(ImageElement image) => image.Attributes.TryGetValue("scale", out var raw) ? LayoutParser.Percentage(raw) : 1; private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch { "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points }; private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable); private enum DragKind { None, Measure, Move, Resize } } public static class OverlayCoordinateMapper { public static Rect PageRect(Size viewport, double pageWidth, double pageHeight) { if (pageWidth <= 0 || pageHeight <= 0 || viewport.Width <= 0 || viewport.Height <= 0) return new Rect(viewport); var scale = Math.Min(viewport.Width / pageWidth, viewport.Height / pageHeight); var width = pageWidth * scale; var height = pageHeight * scale; return new((viewport.Width - width) / 2, (viewport.Height - height) / 2, width, height); } public static Point ToDsl(Point point, Rect displayedPage, double pageWidth, double pageHeight) => new((point.X - displayedPage.X) / displayedPage.Width * pageWidth, (point.Y - displayedPage.Y) / displayedPage.Height * pageHeight); public static Rect ToControl(Rect dslRect, Rect displayedPage, double pageWidth, double pageHeight) => new(displayedPage.X + dslRect.X / pageWidth * displayedPage.Width, displayedPage.Y + dslRect.Y / pageHeight * displayedPage.Height, dslRect.Width / pageWidth * displayedPage.Width, dslRect.Height / pageHeight * displayedPage.Height); } internal static class PointDistanceExtensions { public static double Distance(this Point point, Point other) => Math.Sqrt(Math.Pow(point.X - other.X, 2) + Math.Pow(point.Y - other.Y, 2)); }