292 lines
16 KiB
C#
292 lines
16 KiB
C#
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<Bitmap?> PreviewImageProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, Bitmap?>(nameof(PreviewImage));
|
|
public static readonly StyledProperty<string> LayoutSourceProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(LayoutSource), "");
|
|
public static readonly StyledProperty<double> PageWidthProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageWidth), 210);
|
|
public static readonly StyledProperty<double> PageHeightProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(PageHeight), 297);
|
|
public static readonly StyledProperty<string> PageUnitProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageUnit), "mm");
|
|
public static readonly StyledProperty<OverlayEditorMode> ModeProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, OverlayEditorMode>(nameof(Mode), OverlayEditorMode.Measure);
|
|
public static readonly StyledProperty<bool> SnapToGridProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true);
|
|
public static readonly StyledProperty<double> GridSizeProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(GridSize), 1);
|
|
public static readonly StyledProperty<string> PageTemplateNameProperty =
|
|
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageTemplateName), "first");
|
|
|
|
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 Pen _flowPen = new(new SolidColorBrush(Color.Parse("#7C3AED")), 2, dashStyle: DashStyle.Dash);
|
|
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 IBrush _flowFill = new SolidColorBrush(Color.FromArgb(38, 124, 58, 237));
|
|
private readonly List<OverlayItem> _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 string PageTemplateName { get => GetValue(PageTemplateNameProperty); set => SetValue(PageTemplateNameProperty, value); }
|
|
|
|
public event EventHandler<OverlayMeasurement>? MeasurementCompleted;
|
|
public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged;
|
|
public event EventHandler<string>? CursorCoordinatesChanged;
|
|
public event EventHandler<string>? ElementSelected;
|
|
|
|
static LayoutOverlayEditor()
|
|
{
|
|
AffectsRender<LayoutOverlayEditor>(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty,
|
|
PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty,
|
|
PageTemplateNameProperty);
|
|
}
|
|
|
|
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);
|
|
var fill = selected ? _selectedFill : item.IsFlowSlot ? _flowFill : _normalFill;
|
|
var pen = selected ? _selectedPen : item.IsFlowSlot ? _flowPen : _normalPen;
|
|
context.DrawRectangle(fill, pen,
|
|
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);
|
|
var pageTemplate = layout.PageTemplates.FirstOrDefault(x =>
|
|
x.Name.Equals(PageTemplateName, StringComparison.OrdinalIgnoreCase))
|
|
?? layout.PageTemplates.FirstOrDefault();
|
|
var visibleElements = pageTemplate?.Elements ?? layout.Elements;
|
|
foreach (var element in visibleElements)
|
|
{
|
|
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, false));
|
|
}
|
|
if (pageTemplate is not null)
|
|
foreach (var slot in pageTemplate.FlowSlots)
|
|
_items.Add(new(slot.Line, "FLOW", new Rect(slot.X, slot.Y, slot.Width, slot.Height), true, true));
|
|
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<string, string> 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, bool IsFlowSlot);
|
|
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));
|
|
}
|