diff --git a/LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs b/LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs index 9aea98b..e1d373e 100644 --- a/LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs +++ b/LehrerApp.TemplateDesigner.Tests/OverlayEditorTests.cs @@ -70,4 +70,40 @@ public sealed class OverlayEditorTests Assert.Equal(200, viewModel.OverlayPageHeight); Assert.Equal("pt", viewModel.OverlayPageUnit); } + + [Fact] + public void FlowSlotVerschieben_AendertNurDasPragmaDerAusgewaehltenSeite() + { + var viewModel = new DesignerViewModel(); + var layout = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource); + var firstSlot = layout.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single(); + var continuationSlot = layout.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single(); + + viewModel.ApplyElementGeometry(new(firstSlot.Line, "FLOW", 25, 95, 160, 165)); + + var updated = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource); + var updatedFirst = updated.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single(); + var updatedContinuation = updated.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single(); + Assert.Equal(25, updatedFirst.X); + Assert.Equal(95, updatedFirst.Y); + Assert.Equal(continuationSlot.X, updatedContinuation.X); + Assert.Equal(continuationSlot.Y, updatedContinuation.Y); + } + + [Fact] + public void ElementKannGezieltInContentFlowEingefuegtWerden() + { + var viewModel = new DesignerViewModel + { + NewElementScope = "Content-Flow", SelectedContentFlow = "body", + NewElementType = "TEXT", NewContent = "Nachsatz", NewAttributes = "gap=5", + }; + + viewModel.AddElement(); + + var flow = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource).ContentFlows.Single(); + var text = Assert.IsType(flow.Elements.Last()); + Assert.Equal("Nachsatz", text.Content); + Assert.Equal("5", text.Attributes["gap"]); + } } diff --git a/LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs b/LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs index 593a794..608f4c1 100644 --- a/LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs +++ b/LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs @@ -17,7 +17,9 @@ public sealed class ProjectLifecycleTests viewModel.Reset(); Assert.Equal("neue-vorlage", viewModel.TemplateId); - Assert.Equal("PAGE 210 297 mm\n", viewModel.LayoutSource); + Assert.Contains("#pragma page-template first", viewModel.LayoutSource); + Assert.Contains("#pragma page-template continuation", viewModel.LayoutSource); + Assert.Contains("#pragma content-flow body", viewModel.LayoutSource); Assert.Empty(viewModel.Placeholders); Assert.Empty(viewModel.Assets); Assert.Empty(viewModel.AssetItems); @@ -91,6 +93,21 @@ public sealed class ProjectLifecycleTests Assert.Contains("mehrfach", exception.Message); } + [Fact] + public void AlteAbsoluteLayouts_WerdenBeimOeffnenVerlustfreiInSeitentypEingebettet() + { + const string source = "PAGE 210 297 mm\nTEXT 20 30 \"Altbestand\" size=12\n"; + var layout = new LehrerApp.Templating.LayoutParser().Parse(source); + + var migrated = DesignerViewModel.MigrateLegacyLayout(source, layout); + var parsed = new LehrerApp.Templating.LayoutParser().Parse(migrated); + + Assert.True(parsed.UsesPageTemplates); + Assert.Equal("Altbestand", Assert.IsType( + parsed.PageTemplates.Single(x => x.Name == "first").Elements.Single()).Content); + Assert.Empty(parsed.ContentFlows.Single().Elements); + } + private static readonly byte[] OnePixelPng = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="); } diff --git a/LehrerApp.TemplateDesigner/DesignerViewModel.cs b/LehrerApp.TemplateDesigner/DesignerViewModel.cs index c302fcd..68d6ad3 100644 --- a/LehrerApp.TemplateDesigner/DesignerViewModel.cs +++ b/LehrerApp.TemplateDesigner/DesignerViewModel.cs @@ -8,6 +8,7 @@ namespace LehrerApp.TemplateDesigner; public partial class DesignerViewModel : ObservableObject { + private Bitmap? _pageTemplatePreview; [ObservableProperty] private string _templateId = "elternbrief-standard"; [ObservableProperty] private string _templateName = "Elternbrief Standard"; [ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf"; @@ -23,6 +24,10 @@ public partial class DesignerViewModel : ObservableObject [ObservableProperty] private string _newImageScale = "100"; [ObservableProperty] private string _newContent = "$Brieftext"; [ObservableProperty] private string _newAttributes = "size=11"; + [ObservableProperty] private string _newElementScope = "Seitenvorlage (fest)"; + [ObservableProperty] private string _selectedContentFlow = "body"; + [ObservableProperty] private string _newPageTemplateName = "continuation"; + [ObservableProperty] private string _newFlowName = "body"; [ObservableProperty] private Bitmap? _previewImage; [ObservableProperty] private string _status = "Bereit."; [ObservableProperty] private string _statusColor = "#475569"; @@ -39,10 +44,13 @@ public partial class DesignerViewModel : ObservableObject [ObservableProperty] private double _overlayPageWidth = 210; [ObservableProperty] private double _overlayPageHeight = 297; [ObservableProperty] private string _overlayPageUnit = "mm"; + [ObservableProperty] private string _selectedPageTemplate = "first"; + [ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage; public IReadOnlyList Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList PlaceholderTypes { get; } = Enum.GetValues(); public IReadOnlyList ElementTypes { get; } = ["TEXT", "TEXTBOX", "IMG", "TABLE", "CHART"]; + public IReadOnlyList ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"]; public ObservableCollection Placeholders { get; } = [ new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), @@ -59,6 +67,9 @@ public partial class DesignerViewModel : ObservableObject public Dictionary Assets { get; } = new(StringComparer.OrdinalIgnoreCase); public ObservableCollection AssetItems { get; } = []; public ObservableCollection StarterTemplates { get; } = []; + public ObservableCollection PageTemplateNames { get; } = ["first"]; + public ObservableCollection ContentFlowNames { get; } = ["body"]; + public ObservableCollection PreviewPages { get; } = []; public bool HasSelectedPlaceholder => SelectedPlaceholder is not null; public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null; @@ -70,10 +81,12 @@ public partial class DesignerViewModel : ObservableObject OnPropertyChanged(nameof(HasNoSelectedPlaceholder)); } + partial void OnSelectedPreviewPageChanged(DesignerPreviewPage? value) => PreviewImage = value?.Image; + public void Reset() { TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = ""; - PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n"; + PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = EmptyStructuredLayout; Placeholders.Clear(); SelectedPlaceholder = null; MetadataItems.Clear(); MetadataItems.Add(new(TemplateMetadataKeys.Language, "de-DE")); MetadataItems.Add(new(TemplateMetadataKeys.ReportType, "letter")); @@ -81,7 +94,7 @@ public partial class DesignerViewModel : ObservableObject Assets.Clear(); AssetItems.Clear(); SelectedAsset = null; NewElementType = "TEXT"; NewX = "20"; NewY = "50"; NewWidth = "170"; NewHeight = "30"; NewImageScale = "100"; NewContent = "$Brieftext"; NewAttributes = "size=11"; - PreviewImage?.Dispose(); PreviewImage = null; CanExport = false; + ClearPreviewPages(); CanExport = false; OverlayMode = OverlayEditorMode.Measure; OverlayCoordinates = "x=– · y=–"; SelectedOverlayElement = "Kein Element ausgewählt"; SetStatus("Neues Projekt angelegt.", false); @@ -138,6 +151,8 @@ public partial class DesignerViewModel : ObservableObject public void Load(LoadedTemplate template, string layoutSource) { + if (!template.Layout.UsesPageTemplates) + layoutSource = MigrateLegacyLayout(layoutSource, template.Layout); TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description; PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height; Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource; @@ -157,6 +172,36 @@ public partial class DesignerViewModel : ObservableObject CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false); } + public static string MigrateLegacyLayout(string source, TemplateLayout layout) + { + if (layout.UsesPageTemplates) return source; + var statements = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n') + .Where(line => + { + var trimmed = line.Trim(); + return trimmed.Length > 0 && !trimmed.StartsWith('#') + && !trimmed.StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase); + }).Select(line => line.Trim()).ToList(); + var margin = layout.Unit.Equals("mm", StringComparison.OrdinalIgnoreCase) ? 20f : layout.Width * 0.08f; + var x = FormatNumber(margin); var y = FormatNumber(margin); + var width = FormatNumber(Math.Max(1, layout.Width - margin * 2)); + var height = FormatNumber(Math.Max(1, layout.Height - margin * 2)); + var result = new List + { + $"PAGE {FormatNumber(layout.Width)} {FormatNumber(layout.Height)} {layout.Unit}", + "#pragma format-version 3", "", "#pragma page-template first", + }; + result.AddRange(statements); + result.AddRange([ + $"#pragma flow-slot body x={x} y={y} w={width} h={height}", + "#pragma end-page-template", "", "#pragma page-template continuation", + $"#pragma flow-slot body x={x} y={y} w={width} h={height}", + "#pragma end-page-template", "", "#pragma content-flow body", + "#pragma end-content-flow", "", + ]); + return string.Join('\n', result); + } + public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? newId = null) { Load(template, layoutSource); @@ -169,10 +214,44 @@ public partial class DesignerViewModel : ObservableObject public void PreviewTemplate(LoadedTemplate template) { var provider = BuildSampleDataProvider(template.Manifest); - var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(template, provider); + RenderPreviewPages(template, provider); + SetStatus($"Vorschau von „{template.Manifest.Name}“ mit {PreviewPages.Count} Seite(n).", false); + } + + public void RenderPreviewPages(LoadedTemplate template, ITemplateDataProvider provider) + { + var pages = new QuestTemplateRenderer().RenderPagesToPng(template, provider); + ClearPreviewPages(); + for (var index = 0; index < pages.Count; index++) + { + using var stream = new MemoryStream(pages[index]); + PreviewPages.Add(new(index + 1, new Bitmap(stream))); + } + SelectedPreviewPage = PreviewPages.FirstOrDefault(); + } + + public void PreviewSelectedPageTemplateCanvas() + { + var loaded = BuildLoaded(); + var pageTemplate = loaded.Layout.PageTemplates.FirstOrDefault(x => + x.Name.Equals(SelectedPageTemplate, StringComparison.OrdinalIgnoreCase)); + if (pageTemplate is null) return; + var canvasLayout = new TemplateLayout(loaded.Layout.Width, loaded.Layout.Height, loaded.Layout.Unit, + pageTemplate.Elements); + var canvasTemplate = new LoadedTemplate(loaded.Manifest, canvasLayout, loaded.Assets, loaded.SourceName); + var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(canvasTemplate, BuildDataProvider()); using var stream = new MemoryStream(bytes); - PreviewImage?.Dispose(); PreviewImage = new Bitmap(stream); - SetStatus($"Vorschau von „{template.Manifest.Name}“.", false); + _pageTemplatePreview?.Dispose(); _pageTemplatePreview = new Bitmap(stream); + SelectedPreviewPage = null; PreviewImage = _pageTemplatePreview; + SetStatus($"Bearbeitungsansicht des Seitentyps „{SelectedPageTemplate}“.", false); + } + + private void ClearPreviewPages() + { + _pageTemplatePreview?.Dispose(); _pageTemplatePreview = null; + PreviewImage = null; SelectedPreviewPage = null; + foreach (var page in PreviewPages) page.Image.Dispose(); + PreviewPages.Clear(); } public void SetStarterTemplates(IEnumerable templates, string? selectedId = null) @@ -220,19 +299,84 @@ public partial class DesignerViewModel : ObservableObject public void AddElement() { var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim(); - var line = NewElementType switch + var flowElement = NewElementScope == "Content-Flow"; + var line = (NewElementType, flowElement) switch { - "TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}", - "TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}", - "IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%", - "TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", - "CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", + ("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}", + ("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}", + ("IMG", false) => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%", + ("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", + ("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}", + ("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}", + ("TEXTBOX", true) => $"TEXTBOX {QuoteIfLiteral(NewContent)}{attrs}", + ("IMG", true) => $"IMG {NewContent} w={NewWidth} h={NewHeight} scale={NormalizedImageScale()}%{attrs}", + ("TABLE", true) => $"TABLE {NewContent}{attrs}", + ("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}", _ => throw new InvalidOperationException("Unbekannter Elementtyp."), }; - LayoutSource = LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine; + LayoutSource = InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template", + flowElement ? SelectedContentFlow : SelectedPageTemplate); CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false); } + public void AddPageTemplate() + { + var name = ValidateSectionName(NewPageTemplateName, "Seitentyp"); + if (PageTemplateNames.Contains(name, StringComparer.OrdinalIgnoreCase)) + throw new InvalidDataException($"Seitentyp „{name}“ existiert bereits."); + var block = $"#pragma page-template {name}\n#pragma end-page-template\n"; + var marker = LayoutSource.IndexOf("#pragma content-flow", StringComparison.OrdinalIgnoreCase); + LayoutSource = marker < 0 ? LayoutSource.TrimEnd() + "\n\n" + block + : LayoutSource.Insert(marker, block + "\n"); + SelectedPageTemplate = name; CanExport = false; + SetStatus($"Seitentyp „{name}“ angelegt.", false); + } + + public void AddContentFlow() + { + var name = ValidateSectionName(NewFlowName, "Flow"); + if (ContentFlowNames.Contains(name, StringComparer.OrdinalIgnoreCase)) + throw new InvalidDataException($"Content-Flow „{name}“ existiert bereits."); + LayoutSource = LayoutSource.TrimEnd() + $"\n\n#pragma content-flow {name}\n#pragma end-content-flow\n"; + SelectedContentFlow = name; CanExport = false; + SetStatus($"Content-Flow „{name}“ angelegt. Lege nun gleichnamige Slots auf den Seitentypen an.", false); + } + + public void AddFlowSlot() + { + var name = ValidateSectionName(NewFlowName, "Flow-Slot"); + var line = $"#pragma flow-slot {name} x={NewX} y={NewY} w={NewWidth} h={NewHeight}"; + LayoutSource = InsertIntoSection(LayoutSource, line, "page-template", SelectedPageTemplate); + CanExport = false; + SetStatus($"Flow-Slot „{name}“ auf „{SelectedPageTemplate}“ angelegt.", false); + } + + private static string ValidateSectionName(string value, string label) + { + var name = value.Trim(); + if (name.Length == 0 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '-' or '_'))) + throw new InvalidDataException($"{label}-Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten."); + return name; + } + + private static string InsertIntoSection(string source, string line, string section, string name) + { + var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); + var start = lines.FindIndex(x => + { + var tokens = x.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return tokens.Length >= 3 && tokens[0].Equals("#pragma", StringComparison.OrdinalIgnoreCase) + && tokens[1].Equals(section, StringComparison.OrdinalIgnoreCase) + && tokens[2].Equals(name, StringComparison.OrdinalIgnoreCase); + }); + if (start < 0) throw new InvalidDataException($"{section} „{name}“ wurde nicht gefunden."); + var endDirective = section == "content-flow" ? "end-content-flow" : "end-page-template"; + var end = lines.FindIndex(start + 1, x => x.Trim().Equals($"#pragma {endDirective}", StringComparison.OrdinalIgnoreCase)); + if (end < 0) throw new InvalidDataException($"{section} „{name}“ ist nicht geschlossen."); + lines.Insert(end, line); + return string.Join('\n', lines); + } + public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false); public DesignerAsset ImportBackground(string sourceName, byte[] bytes) @@ -293,6 +437,19 @@ public partial class DesignerViewModel : ObservableObject public void ApplyElementGeometry(OverlayElementGeometry geometry) { var layout = new LayoutParser().Parse(LayoutSource); + if (geometry.Keyword == "FLOW") + { + var slot = layout.PageTemplates.SelectMany(x => x.FlowSlots) + .FirstOrDefault(x => x.Line == geometry.Line) + ?? throw new InvalidDataException($"Flow-Slot in Zeile {geometry.Line} wurde nicht gefunden."); + var slotLines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); + slotLines[geometry.Line - 1] = $"#pragma flow-slot {slot.Name} x={FormatNumber(geometry.X)} " + + $"y={FormatNumber(geometry.Y)} w={FormatNumber(geometry.Width)} h={FormatNumber(geometry.Height)}"; + LayoutSource = string.Join('\n', slotLines); CanExport = false; + SelectedOverlayElement = $"Flow {slot.Name} · Zeile {geometry.Line}"; + SetStatus($"Flow-Slot „{slot.Name}“ verschoben/skaliert.", false); + return; + } var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line) ?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden."); var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); @@ -324,6 +481,20 @@ public partial class DesignerViewModel : ObservableObject { var page = new LayoutParser().Parse(value); OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit; + var names = page.PageTemplates.Select(x => x.Name).ToList(); + if (names.Count == 0) names.Add("legacy"); + var selected = names.Contains(SelectedPageTemplate, StringComparer.OrdinalIgnoreCase) + ? SelectedPageTemplate : names[0]; + PageTemplateNames.Clear(); + foreach (var name in names) PageTemplateNames.Add(name); + SelectedPageTemplate = selected; + var flows = page.ContentFlows.Select(x => x.Name).ToList(); + if (flows.Count == 0) flows.Add("body"); + var selectedFlow = flows.Contains(SelectedContentFlow, StringComparer.OrdinalIgnoreCase) + ? SelectedContentFlow : flows[0]; + ContentFlowNames.Clear(); + foreach (var flow in flows) ContentFlowNames.Add(flow); + SelectedContentFlow = selectedFlow; } catch (TemplateValidationException) { } } @@ -406,15 +577,44 @@ public partial class DesignerViewModel : ObservableObject { Status = text; StatusColor = error ? "#B91C1C" : "#166534"; } private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\""; - private const string DefaultLayout = """ - # Elternbrief Standard + private const string EmptyStructuredLayout = """ PAGE 210 297 mm + #pragma format-version 3 + + #pragma page-template first + #pragma flow-slot body x=20 y=30 w=170 h=247 + #pragma end-page-template + + #pragma page-template continuation + #pragma flow-slot body x=20 y=20 w=170 h=257 + #pragma end-page-template + + #pragma content-flow body + #pragma end-content-flow + """; + + private const string DefaultLayout = """ + # Elternbrief Standard · Format 3 + PAGE 210 297 mm + #pragma format-version 3 + + #pragma page-template first TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A TEXT 20 43 $Datum|dd.MM.yyyy size=10 TEXT 20 55 $Empfaenger size=11 TEXT 20 75 "Sehr geehrte/r $Anrede," size=11 - TEXTBOX 20 90 170 155 $Brieftext size=11 wrap=true - TEXT 20 265 $LehrerName size=10 italic=true + #pragma flow-slot body x=20 y=90 w=170 h=175 + #pragma end-page-template + + #pragma page-template continuation + #pragma flow-slot body x=20 y=25 w=170 h=240 + #pragma end-page-template + + #pragma content-flow body + TEXTBOX $Brieftext size=11 overflow=continue + TEXT "Mit freundlichen Grüßen" size=10 gap=8 keep-with-next=true + TEXT $LehrerName size=10 italic=true gap=4 + #pragma end-content-flow """; } @@ -424,6 +624,11 @@ public partial class DesignerMetadata(string name, string value) : ObservableObj [ObservableProperty] private string _value = value; } +public sealed record DesignerPreviewPage(int Number, Bitmap Image) +{ + public string Display => $"Dokumentseite {Number}"; +} + public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject { [ObservableProperty] private int _usageCount; diff --git a/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs index 1ca8a5a..3e6b28b 100644 --- a/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs +++ b/LehrerApp.TemplateDesigner/LayoutOverlayEditor.cs @@ -30,14 +30,18 @@ public sealed class LayoutOverlayEditor : Control AvaloniaProperty.Register(nameof(SnapToGrid), true); public static readonly StyledProperty GridSizeProperty = AvaloniaProperty.Register(nameof(GridSize), 1); + public static readonly StyledProperty PageTemplateNameProperty = + AvaloniaProperty.Register(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 _items = []; private OverlayItem? _selected; private Point? _pointerStartDsl; @@ -52,6 +56,7 @@ public sealed class LayoutOverlayEditor : Control 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? MeasurementCompleted; public event EventHandler? ElementGeometryChanged; @@ -61,7 +66,8 @@ public sealed class LayoutOverlayEditor : Control static LayoutOverlayEditor() { AffectsRender(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty, - PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty); + PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty, + PageTemplateNameProperty); } public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; } @@ -80,7 +86,9 @@ public sealed class LayoutOverlayEditor : Control 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, + 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); @@ -188,7 +196,11 @@ public sealed class LayoutOverlayEditor : Control try { var layout = new LayoutParser().Parse(LayoutSource); - foreach (var element in layout.Elements) + 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 @@ -201,8 +213,11 @@ public sealed class LayoutOverlayEditor : Control 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)); + _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); } @@ -243,7 +258,7 @@ public sealed class LayoutOverlayEditor : Control 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 sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable, bool IsFlowSlot); private enum DragKind { None, Measure, Move, Resize } } diff --git a/LehrerApp.TemplateDesigner/MainWindow.axaml b/LehrerApp.TemplateDesigner/MainWindow.axaml index 217b538..aaac3d1 100644 --- a/LehrerApp.TemplateDesigner/MainWindow.axaml +++ b/LehrerApp.TemplateDesigner/MainWindow.axaml @@ -34,7 +34,7 @@ - + @@ -171,10 +171,53 @@ + + + + + + + + +