|
- using System.Drawing.Drawing2D;
- using EnvelopeRenderer.Desktop.Core.Design;
-
- namespace EnvelopeRenderer.Desktop.Views;
-
- /// <summary>
- /// The visual canvas surface (Sprint 2 Batch 3: "Place and move text elements on the canvas"):
- /// draws the page and its text elements, and lets the operator select/drag-reposition them with
- /// the mouse. Sprint 4 added: address-line collapse preview (so the canvas visually agrees with
- /// what the CLI would render for a loaded CSV's sample data), rotation (drawing a rotated element
- /// and hit-testing/dragging its rotate handle), and a mapping-error highlight for a dynamic
- /// element bound to a column that isn't in the loaded CSV. All coordinate math and add/select/
- /// drag/rotate state live in <see cref="CanvasElementEditor"/>/<see cref="CanvasViewTransform"/>/
- /// <see cref="AddressBlockPreviewCalculator"/> (all framework-free and unit tested in
- /// EnvelopeRenderer.Desktop.Tests) — this class only does the GDI+ drawing and forwards mouse
- /// events, since that part genuinely cannot be extracted from WinForms.
- /// </summary>
- public sealed class TemplateCanvasControl : Control
- {
- private readonly TemplateLayoutDocument _document;
- private readonly CanvasElementEditor _editor;
- private AddressControlLayout? _selectedAddressControl;
- private int _selectedAddressLineIndex;
- private (double Dx, double Dy)? _addressControlDragOffset;
- private bool _isResizingAddressControl;
-
- /// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
- /// mirrors <see cref="_isResizingAddressControl"/>'s shape — a control-specific drag-gesture
- /// flag, paralleling (not reusing) <see cref="CanvasElementEditor.IsRotating"/>, which only
- /// ever operates on a selected standalone <see cref="TextElementLayout"/>.</summary>
- private bool _isRotatingAddressControl;
-
- /// <summary>Sprint 10, "Select multiple elements at once on the canvas": the Address Control
- /// half of the multi-selection — mirrors <see cref="CanvasElementEditor.MultiSelected"/> for
- /// standalone elements, since <see cref="CanvasElementEditor"/> has no knowledge of Address
- /// Controls at all (the same split as single-selection's <see cref="_selectedAddressControl"/>
- /// vs. <c>CanvasElementEditor.Selected</c>). An *additive* layer: every existing single-select
- /// code path keeps working unchanged whenever this set (plus the editor's) totals fewer than
- /// two items — see <see cref="CollapseMultiSelectionIfSingular"/>.</summary>
- private readonly HashSet<AddressControlLayout> _multiSelectedAddressControls = new();
-
- private bool _isRubberBandSelecting;
- private bool _rubberBandAdditive;
- private (double X, double Y)? _rubberBandStart;
- private (double X, double Y)? _rubberBandCurrent;
-
- /// <summary>Grab point and original positions for the Address Control half of an in-progress
- /// group drag — mirrors <see cref="CanvasElementEditor.BeginMultiDrag"/>/<c>MultiDragTo</c> for
- /// standalone elements, applied to both halves from the same shared delta every tick so the
- /// whole mixed multi-selection moves together.</summary>
- private (double X, double Y)? _multiDragGrabPoint;
- private Dictionary<AddressControlLayout, (double X, double Y)>? _multiDragOriginalAddressPositions;
-
- /// <summary>The currently loaded CSV's headers and one representative sample record, used
- /// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
- /// empty/<c>null</c> until <see cref="SetCsvPreviewContext"/> is called after a CSV loads.</summary>
- private IReadOnlyList<string> _csvHeaders = Array.Empty<string>();
- private IReadOnlyDictionary<string, string>? _csvSampleRecord;
-
- public event EventHandler? SelectionChanged;
- public event EventHandler? ElementsChanged;
-
- public TemplateCanvasControl(TemplateLayoutDocument document)
- {
- _document = document;
- _editor = new CanvasElementEditor(document, MeasureElement);
-
- DoubleBuffered = true;
- AllowDrop = true;
- BackColor = SystemColors.ControlDark;
- SetStyle(ControlStyles.ResizeRedraw, true);
- // Post-Sprint-9: a plain Control (unlike UserControl) is not selectable/focusable by
- // default, so it never received key events at all — needed for the Delete key to remove
- // the selected element (see OnKeyDown below and RemoveSelectedElement's own remarks on
- // the reported gap this fixes).
- SetStyle(ControlStyles.Selectable, true);
- TabStop = true;
- }
-
- /// <summary>Sprint 7, "Snap elements to grid and guides": mirrors
- /// <see cref="CanvasElementEditor.SnapToGridEnabled"/> so the same toggle governs standalone
- /// element dragging (handled inside <see cref="CanvasElementEditor"/>) and Address Control
- /// move/resize (handled directly in this control's mouse handlers below) uniformly, and so
- /// this control knows whether to paint grid lines.</summary>
- [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
- public bool SnapToGridEnabled
- {
- get => _editor.SnapToGridEnabled;
- set
- {
- _editor.SnapToGridEnabled = value;
- Invalidate();
- }
- }
-
- /// <summary>Sprint 7: the grid increment (canvas-space points) snapping rounds to and grid
- /// lines are painted at, when <see cref="SnapToGridEnabled"/> is <c>true</c>.</summary>
- [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
- public double GridSizePoints
- {
- get => _editor.GridSizePoints;
- set
- {
- _editor.GridSizePoints = value > 0 ? value : CanvasElementEditor.DefaultGridSizePoints;
- Invalidate();
- }
- }
-
- /// <summary>True while an active move, resize, or rotate gesture is in progress on the
- /// canvas — a standalone element drag/rotate (<see cref="CanvasElementEditor"/>) or an
- /// Address Control move/resize/rotate. Post-Sprint-8 smoothness fix: lets
- /// <see cref="TemplateDesignerForm"/> skip its full properties-panel refresh (which
- /// repopulates the rebind-column combo box from every loaded CSV header on each call — real
- /// work only for a single-column-bound dynamic element) on every mouse-move tick of a
- /// gesture, syncing just the position/angle fields the gesture can actually change instead.
- /// Mirrors the exact condition <see cref="OnMouseMove"/> already used inline before this
- /// property existed.</summary>
- public bool IsInteracting =>
- _editor.IsDragging || _editor.IsRotating || _editor.IsResizing || _addressControlDragOffset is not null
- || _isResizingAddressControl || _isRotatingAddressControl || _editor.IsMultiDragging;
-
- public TextElementLayout? SelectedElement => _editor.Selected;
- public AddressControlLayout? SelectedAddressControl => _selectedAddressControl;
- public int SelectedAddressLineIndex => _selectedAddressLineIndex;
-
- /// <summary>Sprint 10, "Select multiple elements at once on the canvas": the combined size of
- /// the multi-selection across both standalone elements and Address Controls. Zero or one means
- /// no *active* multi-selection — see <see cref="IsMultiSelectionActive"/> and
- /// <see cref="CollapseMultiSelectionIfSingular"/> for why a single leftover item always
- /// collapses back into the ordinary single-selection fields instead of staying here.</summary>
- public int MultiSelectionCount => _editor.MultiSelected.Count + _multiSelectedAddressControls.Count;
-
- /// <summary>True while two or more items (any mix of standalone elements and Address Controls)
- /// are selected together. <see cref="TemplateDesignerForm"/> uses this to show "N items
- /// selected" and disable per-item property editing instead of displaying stale/misleading
- /// single-item values.</summary>
- public bool IsMultiSelectionActive => MultiSelectionCount >= 2;
- public AddressControlLineLayout? SelectedAddressLine =>
- _selectedAddressControl is not null
- && _selectedAddressLineIndex >= 0
- && _selectedAddressLineIndex < _selectedAddressControl.Lines.Count
- ? _selectedAddressControl.Lines[_selectedAddressLineIndex]
- : null;
-
- public TextElementLayout AddStaticTextElement()
- {
- var (x, y) = DefaultNewElementPosition();
- var element = _editor.AddStaticText(x, y);
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return element;
- }
-
- /// <summary>Adds a filled rectangle (a text element with an empty content, a box and a fill
- /// color) that covers whatever is under it and can have text typed into it. White by default,
- /// like a cover-up patch.</summary>
- public TextElementLayout AddRectangleElement()
- {
- var (x, y) = DefaultNewElementPosition();
- var element = _editor.AddStaticText(x, y, text: string.Empty);
- element.Width = DefaultRectangleWidth;
- element.Height = DefaultRectangleHeight;
- element.FillColor = new RgbColor(255, 255, 255);
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return element;
- }
-
- public const double DefaultRectangleWidth = 120;
- public const double DefaultRectangleHeight = 40;
-
- public AddressControlLayout AddAddressControl()
- {
- var (x, y) = DefaultNewElementPosition();
- var control = AddressControlLayout.CreateDefault(x, y, _document.NextZOrder());
- _document.AddressControls.Add(control);
- SelectAddressControl(control, 0);
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return control;
- }
-
- public void AddAddressLine()
- {
- if (_selectedAddressControl is null)
- {
- return;
- }
-
- _selectedAddressControl.AddLine();
- _selectedAddressLineIndex = _selectedAddressControl.Lines.Count - 1;
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
-
- public void RemoveSelectedAddressLine()
- {
- if (_selectedAddressControl is null)
- {
- return;
- }
-
- if (_selectedAddressControl.RemoveLineAt(_selectedAddressLineIndex))
- {
- _selectedAddressLineIndex = Math.Min(_selectedAddressLineIndex, _selectedAddressControl.Lines.Count - 1);
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
- }
-
- public void SelectAddressLine(int lineIndex)
- {
- if (_selectedAddressControl is null)
- {
- return;
- }
-
- _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, _selectedAddressControl.Lines.Count - 1));
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- }
-
- public void MoveSelectedAddressLineUp()
- {
- if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineUp(_selectedAddressLineIndex))
- {
- _selectedAddressLineIndex--;
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
- }
-
- public void MoveSelectedAddressLineDown()
- {
- if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineDown(_selectedAddressLineIndex))
- {
- _selectedAddressLineIndex++;
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
- }
-
- /// <summary>Drag-and-drop data format carrying a CSV column header name from the form's CSV
- /// grid onto this canvas.</summary>
- public const string ColumnDragFormat = "EnvelopeRenderer.CsvColumn";
-
- /// <summary>Drops a CSV column onto the canvas at a client-space pixel position: onto an
- /// Address Control it appends a new line bound to the column; anywhere else it places a new
- /// dynamic placeholder there. Ignored in show-data (read-only) mode.</summary>
- public bool DropColumn(string columnName, Point clientPoint)
- {
- if (_showData || string.IsNullOrEmpty(columnName))
- {
- return false;
- }
-
- var (x, y) = CurrentTransform().ToPoints(clientPoint.X, clientPoint.Y);
- var (control, _) = HitTestAddressControl(x, y);
- if (control is not null)
- {
- control.Lines.Add(AddressControlLineLayout.CreateField(columnName));
- SelectAddressControl(control, control.Lines.Count - 1);
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return true;
- }
-
- _editor.AddDynamicPlaceholder(x, y, columnName);
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return true;
- }
-
- protected override void OnDragOver(DragEventArgs drgevent)
- {
- base.OnDragOver(drgevent);
- drgevent.Effect = !_showData && drgevent.Data?.GetDataPresent(ColumnDragFormat) == true
- ? DragDropEffects.Copy
- : DragDropEffects.None;
- }
-
- protected override void OnDragDrop(DragEventArgs drgevent)
- {
- base.OnDragDrop(drgevent);
- if (drgevent.Data?.GetData(ColumnDragFormat) is string column)
- {
- DropColumn(column, PointToClient(new Point(drgevent.X, drgevent.Y)));
- }
- }
-
- public TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column")
- {
- var (x, y) = DefaultNewElementPosition();
- var element = _editor.AddDynamicPlaceholder(x, y, columnName);
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return element;
- }
-
- /// <summary>Sprint 4: supplies the loaded CSV's headers and one representative sample record
- /// (typically the first loaded sample row) so the canvas can preview address-line collapsing
- /// and flag mapping errors exactly the way a real render would. Pass an empty header list and
- /// <c>null</c> record to clear the preview context (e.g. nothing loaded yet).</summary>
- public void SetCsvPreviewContext(IReadOnlyList<string> headers, IReadOnlyDictionary<string, string>? sampleRecord)
- {
- _csvHeaders = headers;
- _csvSampleRecord = sampleRecord;
- Invalidate();
- }
-
- private bool _showData;
-
- /// <summary>"Show data" view mode: the canvas draws each element's text resolved against the
- /// current record (the one supplied via <see cref="SetCsvPreviewContext"/>) instead of the
- /// literal <c>{Column}</c> tokens with their blue placeholder fills. Read-only while on — hit
- /// testing and measuring work from the token text, so editing gestures are ignored until the
- /// operator switches back to design view.</summary>
- [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
- public bool ShowData
- {
- get => _showData;
- set
- {
- if (_showData == value)
- {
- return;
- }
-
- _showData = value;
- Invalidate();
- }
- }
-
- /// <summary>The text an element is drawn with: its resolved value for the current record in
- /// show-data mode (falling back to the token text if it cannot be resolved), otherwise the
- /// token text.</summary>
- private string DisplayTextFor(TextElementLayout element) =>
- _showData
- ? TextResolver.TryResolve(element.Runs, _csvHeaders, _csvSampleRecord) ?? element.DisplayText
- : element.DisplayText;
-
- /// <summary>Re-selects the given element (e.g. after the properties panel changes it) and
- /// redraws — used so external edits stay visually in sync with the canvas.</summary>
- public void NotifyElementChanged()
- {
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
-
- /// <summary>Clears the current selection and redraws — used after reopening a saved template
- /// (Batch 5), since a freshly loaded document's elements are new object instances and any
- /// previously selected element instance no longer belongs to it.</summary>
- public void ClearSelection()
- {
- _editor.Select(null);
- _editor.ClearMultiSelect();
- _selectedAddressControl = null;
- _selectedAddressLineIndex = 0;
- _addressControlDragOffset = null;
- _isResizingAddressControl = false;
- _isRotatingAddressControl = false;
- _multiSelectedAddressControls.Clear();
- _isRubberBandSelecting = false;
- _rubberBandStart = null;
- _rubberBandCurrent = null;
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- }
-
- /// <summary>Post-Sprint-9 user-reported gap: there was no way to delete a placed element or
- /// Address Control at all once added. Removes whichever is currently selected (a standalone
- /// element or a whole Address Control — never just a drilled-into address line, which has its
- /// own dedicated <see cref="RemoveSelectedAddressLine"/>) and clears the selection. Returns
- /// <c>false</c> (a no-op) if nothing is selected.</summary>
- public bool RemoveSelectedElement()
- {
- if (_selectedAddressControl is not null)
- {
- var removed = _document.AddressControls.Remove(_selectedAddressControl);
- ClearSelection();
- if (removed)
- {
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
-
- return removed;
- }
-
- if (_editor.Selected is not null)
- {
- var removed = _editor.RemoveSelected();
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- if (removed)
- {
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
-
- return removed;
- }
-
- return false;
- }
-
- /// <summary>Sprint 10, "Align and distribute multiple elements": aligns every item in the
- /// current multi-selection to the given <paramref name="edge"/> of the selection's combined
- /// bounding box. A no-op below 2 selected items — callers (the alignment toolbar buttons) are
- /// expected to disable themselves via <see cref="MultiSelectionCount"/> rather than relying on
- /// this guard alone, per this story's "clearly disabled, not a silent no-op" AC.</summary>
- public void AlignSelection(AlignmentCalculator.Edge edge)
- {
- if (MultiSelectionCount < 2)
- {
- return;
- }
-
- ApplySelectionDeltas(AlignmentCalculator.ComputeAlignmentDeltas(GetSelectionBounds(out var elements, out var controls), edge), elements, controls);
- }
-
- /// <summary>Distributes the current multi-selection with equal center-to-center spacing along
- /// the given <paramref name="axis"/> — see <see cref="AlignmentCalculator.ComputeDistributionDeltas"/>
- /// for the documented MVP center-spacing simplification. A no-op below 2 selected items (and,
- /// per that method's own remarks, has no visible effect below 3, since there is nothing to
- /// place between the two extreme items).</summary>
- public void DistributeSelection(AlignmentCalculator.Axis axis)
- {
- if (MultiSelectionCount < 2)
- {
- return;
- }
-
- ApplySelectionDeltas(AlignmentCalculator.ComputeDistributionDeltas(GetSelectionBounds(out var elements, out var controls), axis), elements, controls);
- }
-
- /// <summary>Builds the combined, fixed-order bounding-box list <see cref="AlignmentCalculator"/>
- /// operates on — standalone elements first, then Address Controls — so a returned delta list
- /// can be zipped back to the exact item it applies to via <paramref name="elements"/>/
- /// <paramref name="controls"/>' matching order.</summary>
- private List<(double MinX, double MinY, double MaxX, double MaxY)> GetSelectionBounds(
- out List<TextElementLayout> elements, out List<AddressControlLayout> controls)
- {
- elements = _editor.MultiSelected.ToList();
- controls = _multiSelectedAddressControls.ToList();
-
- var bounds = new List<(double MinX, double MinY, double MaxX, double MaxY)>(elements.Count + controls.Count);
- bounds.AddRange(elements.Select(_editor.GetWorldBounds));
- bounds.AddRange(controls.Select(GetAddressControlWorldBounds));
- return bounds;
- }
-
- private void ApplySelectionDeltas(
- IReadOnlyList<(double Dx, double Dy)> deltas, List<TextElementLayout> elements, List<AddressControlLayout> controls)
- {
- for (var i = 0; i < elements.Count; i++)
- {
- elements[i].X += deltas[i].Dx;
- elements[i].Y += deltas[i].Dy;
- }
-
- for (var i = 0; i < controls.Count; i++)
- {
- var delta = deltas[elements.Count + i];
- controls[i].X += delta.Dx;
- controls[i].Y += delta.Dy;
- }
-
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
-
- private (double X, double Y) DefaultNewElementPosition()
- {
- // Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
- // on top of the last one.
- var count = _document.Elements.Count + _document.AddressControls.Count;
- var x = Math.Min(_document.Canvas.WidthPoints * 0.1 + (count * 10), _document.Canvas.WidthPoints - 20);
- var y = Math.Max(_document.Canvas.HeightPoints * 0.8 - (count * 10), 10);
- return (x, y);
- }
-
- private CanvasViewTransform CurrentTransform() =>
- CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height);
-
- /// <summary>Folder relative background paths resolve against (the template folder).</summary>
- [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
- public string? BackgroundBaseDirectory { get; set; }
-
- private string? _backgroundImagePath;
- private DateTime _backgroundImageStamp;
- private Image? _backgroundImage;
-
- /// <summary>Paints the template page background under everything else. Images are drawn
- /// stretched to the page (matching the render). A PDF cannot be rasterized by GDI+, so the page
- /// shows a labelled placeholder - the real PDF page appears in the rendered output.</summary>
- private void DrawPageBackground(Graphics g, RectangleF pageRect)
- {
- var background = _document.Background;
- if (background is null)
- {
- return;
- }
-
- var file = background.ResolveFile(_csvSampleRecord, BackgroundBaseDirectory);
- if (file is null)
- {
- return;
- }
-
- if (PageBackgroundLayout.IsPdf(file))
- {
- var page = background.ResolvePage(_csvSampleRecord);
- var pdfBitmap = page is { } pageNumber ? LoadPdfBackground(file, pageNumber, (int)pageRect.Width, out var pdfError) : null;
- if (pdfBitmap is not null)
- {
- g.DrawImage(pdfBitmap, pageRect);
- return;
- }
-
- using var tint = new SolidBrush(System.Drawing.Color.FromArgb(235, 240, 250));
- g.FillRectangle(tint, pageRect);
- using var font = new Font("Segoe UI", 9f);
- g.DrawString(
- $"PDF background: {Path.GetFileName(file)}, page {(page?.ToString() ?? "?")}\n(could not be shown here: {_pdfBackgroundError ?? "invalid page"})",
- font, Brushes.SlateGray, pageRect.X + 6, pageRect.Y + 6);
- return;
- }
-
- var image = LoadBackgroundImage(file);
- if (image is not null)
- {
- g.DrawImage(image, pageRect);
- }
- }
-
- private string? _pdfBackgroundKey;
- private int _pdfBackgroundWidth;
- private Bitmap? _pdfBackgroundBitmap;
- private string? _pdfBackgroundError;
-
- /// <summary>The PDF page rendered with Debenu DARenderPageToDC, cached per (file, page, file
- /// timestamp) and re-rendered only when the canvas page width changes by more than ~25% (zoom),
- /// so a repaint during drag or selection never re-renders the PDF.</summary>
- private Bitmap? LoadPdfBackground(string path, int page, int pageWidthPixels, out string? error)
- {
- var stamp = File.Exists(path) ? File.GetLastWriteTimeUtc(path).Ticks : 0;
- var key = $"{path}|{page}|{stamp}";
- var wanted = Math.Max(pageWidthPixels, 50);
- var sameKey = _pdfBackgroundKey == key;
- var sizeOk = _pdfBackgroundWidth > 0 && wanted <= _pdfBackgroundWidth * 1.25 && wanted >= _pdfBackgroundWidth * 0.75;
-
- if (sameKey && (_pdfBackgroundBitmap is null ? _pdfBackgroundError is not null : sizeOk || _pdfBackgroundWidth >= PdfPageBitmapRenderer.MaxPixelWidth))
- {
- error = _pdfBackgroundError;
- return _pdfBackgroundBitmap;
- }
-
- var rendered = PdfPageBitmapRenderer.Render(path, page, wanted, out var renderError);
- _pdfBackgroundBitmap?.Dispose();
- _pdfBackgroundBitmap = rendered;
- _pdfBackgroundKey = key;
- _pdfBackgroundWidth = rendered is null ? 0 : wanted;
- _pdfBackgroundError = renderError;
- error = renderError;
- return rendered;
- }
-
- private Image? LoadBackgroundImage(string path)
- {
- try
- {
- var stamp = File.GetLastWriteTimeUtc(path);
- if (_backgroundImage is not null && _backgroundImagePath == path && _backgroundImageStamp == stamp)
- {
- return _backgroundImage;
- }
-
- _backgroundImage?.Dispose();
- _backgroundImage = null;
- _backgroundImagePath = path;
- _backgroundImageStamp = stamp;
- // Copy through a MemoryStream so the file is not left locked by GDI+.
- using var stream = new MemoryStream(File.ReadAllBytes(path));
- using var loaded = Image.FromStream(stream);
- _backgroundImage = new Bitmap(loaded);
- }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OutOfMemoryException)
- {
- _backgroundImage = null;
- }
-
- return _backgroundImage;
- }
-
- protected override void OnPaint(PaintEventArgs e)
- {
- base.OnPaint(e);
- var g = e.Graphics;
- g.SmoothingMode = SmoothingMode.AntiAlias;
- g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
-
- var transform = CurrentTransform();
- var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints);
- var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0);
- var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom);
-
- g.FillRectangle(Brushes.White, pageRect);
- DrawPageBackground(g, pageRect);
- g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);
-
- // Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so
- // alignment is visible, not just felt during a drag — drawn under every element so it
- // never obscures selection/rotate-handle/mapping-warning visuals.
- if (SnapToGridEnabled)
- {
- DrawGrid(g, transform);
- }
-
- // Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render
- // time, run here against the loaded CSV's sample record so the canvas preview and the
- // final PDF agree (the story's "Preview and final render must agree" conversation note).
- var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord);
-
- var paintItems = new List<(int ZOrder, TextElementLayout? Text, AddressControlLayout? Control)>();
- paintItems.AddRange(_document.Elements.Select(e => (e.ZOrder, Text: (TextElementLayout?)e, Control: (AddressControlLayout?)null)));
- paintItems.AddRange(_document.AddressControls.Select(c => (c.ZOrder, Text: (TextElementLayout?)null, Control: (AddressControlLayout?)c)));
- foreach (var item in paintItems.OrderBy(i => i.ZOrder))
- {
- if (item.Text is not null)
- {
- var state = previewStates[item.Text.Id];
- if (!state.Visible)
- {
- continue;
- }
-
- // Sprint 10, "Select multiple elements at once on the canvas": a multi-selected
- // element gets the same highlight border as the single primary selection (see
- // DrawElement's isSelected remarks — it only ever drives the highlight, never the
- // resize/rotate handles, which are drawn separately below gated on
- // _editor.Selected alone) so every selected item is visibly marked, not just one.
- DrawElement(
- g, transform, item.Text, state,
- isSelected: ReferenceEquals(item.Text, _editor.Selected) || _editor.MultiSelected.Contains(item.Text));
- continue;
- }
-
- DrawAddressControl(
- g,
- transform,
- item.Control!,
- isPrimarySelected: ReferenceEquals(item.Control, _selectedAddressControl),
- isMultiSelected: _multiSelectedAddressControls.Contains(item.Control!));
- }
-
- if (_editor.Selected is not null)
- {
- DrawResizeHandle(g, transform, _editor.Selected);
- DrawHeightHandle(g, transform);
- DrawRotateHandle(g, transform, _editor.Selected);
- }
-
- if (_isRubberBandSelecting && _rubberBandStart is not null && _rubberBandCurrent is not null)
- {
- DrawRubberBand(g, transform, _rubberBandStart.Value, _rubberBandCurrent.Value);
- }
- }
-
- /// <summary>Sprint 10, "Select multiple elements at once on the canvas": the marquee rectangle
- /// drawn while a rubber-band selection drag is in progress, in canvas-page-agnostic pixel
- /// space (drawn last, on top of everything else, the same way a rotate/resize handle already
- /// draws on top of its element).</summary>
- private static void DrawRubberBand(
- Graphics g, CanvasViewTransform transform, (double X, double Y) start, (double X, double Y) current)
- {
- var (x1, y1) = transform.ToPixels(start.X, start.Y);
- var (x2, y2) = transform.ToPixels(current.X, current.Y);
- var rect = RectangleF.FromLTRB(
- (float)Math.Min(x1, x2), (float)Math.Min(y1, y2), (float)Math.Max(x1, x2), (float)Math.Max(y1, y2));
-
- using var fill = new SolidBrush(System.Drawing.Color.FromArgb(40, System.Drawing.Color.DodgerBlue));
- using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
- g.FillRectangle(fill, rect);
- g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
- }
-
- /// <summary>Sprint 7: draws light dotted grid lines across the page at every
- /// <see cref="GridSizePoints"/> interval, in both directions, so an operator can see the
- /// alignment grid snapping is rounding positions to.</summary>
- private void DrawGrid(Graphics g, CanvasViewTransform transform)
- {
- var gridSize = GridSizePoints;
- if (gridSize <= 0)
- {
- return;
- }
-
- using var gridPen = new Pen(System.Drawing.Color.FromArgb(110, System.Drawing.Color.SteelBlue), 1)
- {
- DashStyle = DashStyle.Dot,
- };
-
- for (var x = 0.0; x <= _document.Canvas.WidthPoints; x += gridSize)
- {
- var (x1, y1) = transform.ToPixels(x, 0);
- var (x2, y2) = transform.ToPixels(x, _document.Canvas.HeightPoints);
- g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
- }
-
- for (var y = 0.0; y <= _document.Canvas.HeightPoints; y += gridSize)
- {
- var (x1, y1) = transform.ToPixels(0, y);
- var (x2, y2) = transform.ToPixels(_document.Canvas.WidthPoints, y);
- g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
- }
- }
-
- private void SelectAddressControl(AddressControlLayout control, int lineIndex)
- {
- _selectedAddressControl = control;
- _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, control.Lines.Count - 1));
- _editor.Select(null);
- }
-
- private void ClearAddressSelection()
- {
- _selectedAddressControl = null;
- _selectedAddressLineIndex = 0;
- _addressControlDragOffset = null;
- _isResizingAddressControl = false;
- _isRotatingAddressControl = false;
- }
-
- /// <summary>Sprint 10, "Select multiple elements at once on the canvas": empties both halves
- /// of the multi-selection (standalone elements and Address Controls) without touching the
- /// ordinary single-selection fields — callers that are about to establish a fresh single
- /// selection call this first so a stale multi-selection never lingers alongside it.</summary>
- private void ClearMultiSelection()
- {
- _editor.ClearMultiSelect();
- _multiSelectedAddressControls.Clear();
- }
-
- /// <summary>Applies the multi-selection's one collapse rule after every mutation (a rubber-band
- /// release, a modifier-click toggle): zero items means nothing is selected at all; exactly one
- /// item collapses back into the ordinary single-selection fields (<see cref="CanvasElementEditor.Selected"/>
- /// or <see cref="_selectedAddressControl"/>) so every existing single-select code path —
- /// properties panel, rotate/resize handles, address-line drill-in — keeps working completely
- /// unchanged; two or more items clears both single-selection fields so the properties panel
- /// shows "N items selected" instead of stale single-item values.</summary>
- private void CollapseMultiSelectionIfSingular()
- {
- var multiElements = _editor.MultiSelected;
- var total = multiElements.Count + _multiSelectedAddressControls.Count;
-
- if (total == 0)
- {
- _editor.Select(null);
- ClearAddressSelection();
- return;
- }
-
- if (total == 1)
- {
- if (multiElements.Count == 1)
- {
- var onlyElement = multiElements.First();
- _editor.ClearMultiSelect();
- _editor.Select(onlyElement);
- ClearAddressSelection();
- }
- else
- {
- var onlyControl = _multiSelectedAddressControls.First();
- _multiSelectedAddressControls.Clear();
- SelectAddressControl(onlyControl, 0);
- }
-
- return;
- }
-
- // Two or more: neither single-selection field applies while a multi-selection is active.
- _editor.Select(null);
- ClearAddressSelection();
- }
-
- /// <summary>Modifier-click (Ctrl/Shift) support: toggles the given element's membership in the
- /// multi-selection. If nothing was multi-selected yet, first seeds the set with whatever was
- /// singly selected — the standard modifier-click UX extends the current selection rather than
- /// starting over from empty.</summary>
- private void ToggleElementMultiSelect(TextElementLayout element)
- {
- SeedMultiSelectionFromSingleSelectionIfEmpty();
- _editor.ToggleMultiSelect(element);
- CollapseMultiSelectionIfSingular();
- }
-
- private void ToggleAddressControlMultiSelect(AddressControlLayout control)
- {
- SeedMultiSelectionFromSingleSelectionIfEmpty();
- if (!_multiSelectedAddressControls.Remove(control))
- {
- _multiSelectedAddressControls.Add(control);
- }
-
- CollapseMultiSelectionIfSingular();
- }
-
- private void SeedMultiSelectionFromSingleSelectionIfEmpty()
- {
- if (_editor.MultiSelected.Count > 0 || _multiSelectedAddressControls.Count > 0)
- {
- return;
- }
-
- if (_editor.Selected is not null)
- {
- _editor.SetMultiSelection(new[] { _editor.Selected });
- }
-
- if (_selectedAddressControl is not null)
- {
- _multiSelectedAddressControls.Add(_selectedAddressControl);
- }
- }
-
- /// <summary>Rubber-band selection support: every Address Control whose world-space
- /// (rotation-aware) axis-aligned bounding box intersects the given rectangle — the Address
- /// Control counterpart of <see cref="CanvasElementEditor.ElementsInRect"/>, kept here rather
- /// than in Desktop.Core since it needs <see cref="MaxLineFontSize"/>, the same WinForms-only
- /// geometry helper <see cref="DrawAddressControlUnrotated"/> already uses.</summary>
- private List<AddressControlLayout> AddressControlsInRect(double minX, double minY, double maxX, double maxY)
- {
- var result = new List<AddressControlLayout>();
- foreach (var control in _document.AddressControls)
- {
- var (elMinX, elMinY, elMaxX, elMaxY) = GetAddressControlWorldBounds(control);
- if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
- {
- result.Add(control);
- }
- }
-
- return result;
- }
-
- /// <summary>Sprint 10, "Align and distribute multiple elements": the same rotation-aware
- /// world-space AABB <see cref="AddressControlsInRect"/> already computed inline for rubber-band
- /// hit-testing, extracted so alignment/distribution can feed it into
- /// <see cref="AlignmentCalculator"/> alongside <see cref="CanvasElementEditor.GetWorldBounds"/>
- /// for standalone elements.</summary>
- private static (double MinX, double MinY, double MaxX, double MaxY) GetAddressControlWorldBounds(AddressControlLayout control)
- {
- var top = control.TopBaselineY + MaxLineFontSize(control);
- var bottom = control.TopBaselineY - control.Height;
- var left = control.X;
- var right = control.X + control.Width;
-
- if (control.RotationAngle == 0)
- {
- return (left, bottom, right, top);
- }
-
- var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) };
- var minX = double.MaxValue;
- var minY = double.MaxValue;
- var maxX = double.MinValue;
- var maxY = double.MinValue;
- foreach (var (cornerX, cornerY) in corners)
- {
- var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle);
- minX = Math.Min(minX, rx);
- maxX = Math.Max(maxX, rx);
- minY = Math.Min(minY, ry);
- maxY = Math.Max(maxY, ry);
- }
-
- return (minX, minY, maxX, maxY);
- }
-
- /// <summary>Finalizes a rubber-band drag on release: computes the final (normalized) rectangle,
- /// finds every element/control it intersects, and either replaces the multi-selection with that
- /// set (a plain drag) or adds it to whatever was already multi-selected (a modifier-held drag,
- /// <see cref="_rubberBandAdditive"/>) — mirroring modifier-click's "extend, don't replace"
- /// behavior for consistency.</summary>
- private void FinalizeRubberBandSelection()
- {
- if (_rubberBandStart is not null && _rubberBandCurrent is not null)
- {
- var (x1, y1) = _rubberBandStart.Value;
- var (x2, y2) = _rubberBandCurrent.Value;
- var minX = Math.Min(x1, x2);
- var maxX = Math.Max(x1, x2);
- var minY = Math.Min(y1, y2);
- var maxY = Math.Max(y1, y2);
-
- var hitElements = _editor.ElementsInRect(minX, minY, maxX, maxY);
- var hitControls = AddressControlsInRect(minX, minY, maxX, maxY);
-
- if (_rubberBandAdditive)
- {
- var combinedElements = new HashSet<TextElementLayout>(_editor.MultiSelected);
- foreach (var element in hitElements)
- {
- combinedElements.Add(element);
- }
-
- _editor.SetMultiSelection(combinedElements);
- foreach (var control in hitControls)
- {
- _multiSelectedAddressControls.Add(control);
- }
- }
- else
- {
- _editor.SetMultiSelection(hitElements);
- _multiSelectedAddressControls.Clear();
- foreach (var control in hitControls)
- {
- _multiSelectedAddressControls.Add(control);
- }
- }
-
- CollapseMultiSelectionIfSingular();
- }
-
- _isRubberBandSelecting = false;
- _rubberBandStart = null;
- _rubberBandCurrent = null;
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- }
-
- private void BeginGroupDrag(double grabXPoints, double grabYPoints)
- {
- _editor.BeginMultiDrag(grabXPoints, grabYPoints);
- _multiDragGrabPoint = (grabXPoints, grabYPoints);
- _multiDragOriginalAddressPositions = _multiSelectedAddressControls.ToDictionary(c => c, c => (c.X, c.Y));
- }
-
- private void ApplyMultiDragToAddressControls(double xPoints, double yPoints)
- {
- if (_multiDragGrabPoint is null || _multiDragOriginalAddressPositions is null)
- {
- return;
- }
-
- var dx = xPoints - _multiDragGrabPoint.Value.X;
- var dy = yPoints - _multiDragGrabPoint.Value.Y;
- if (SnapToGridEnabled)
- {
- dx = GridSnapper.Snap(dx, GridSizePoints);
- dy = GridSnapper.Snap(dy, GridSizePoints);
- }
-
- foreach (var (control, original) in _multiDragOriginalAddressPositions)
- {
- control.X = original.X + dx;
- control.Y = original.Y + dy;
- }
- }
-
- private void EndGroupDrag()
- {
- _editor.EndMultiDrag();
- _multiDragGrabPoint = null;
- _multiDragOriginalAddressPositions = null;
- }
-
- private void DrawElement(
- Graphics g, CanvasViewTransform transform, TextElementLayout element,
- ElementPreviewState state, bool isSelected)
- {
- var effectiveY = state.EffectiveY;
- using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
- var (width, height) = MeasureElement(element);
-
- // Element (X, effectiveY) is the bottom-left, baseline-ish origin in canvas space (points,
- // bottom-left page origin); the drawn box spans up to (X + width, effectiveY + height), so
- // the pixel position to draw the string's top-left corner at is the transform of
- // (X, effectiveY + height). `effectiveY` is the same as `element.Y` unless Sprint 4's
- // address-line collapsing has shifted it (see AddressBlockPreviewCalculator).
- var (drawX, boxTopY) = transform.ToPixels(element.X, effectiveY + height);
- using var drawFont = ResolveDrawFont(element.FontFamily, (float)element.FontSize, transform.Scale);
- // Plain (non-box) text is positioned by baseline like the PDF; a boxed element's text
- // hangs from the top of its box (PDF DrawTextBox).
- var drawY = element.HasBox
- ? boxTopY
- : transform.ToPixels(element.X, effectiveY).Y - BaselineOffsetPx(drawFont);
-
- GraphicsState? savedState = null;
- if (element.RotationAngle != 0)
- {
- var (pivotX, pivotY) = RotationPivot(element, effectiveY, width, height);
- var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
- savedState = g.Save();
- g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
- // GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this
- // Y-down pixel space. The stored RotationAngle uses the opposite convention —
- // counterclockwise-positive, confirmed empirically against the real Debenu DLL (see
- // RotatedTextAnchorCalculator's class remarks in EnvelopeRenderer.Cli) — so the angle
- // is negated here to keep the canvas rotating the same visual direction the final PDF
- // will, per this story's "same rotation, around the same pivot, as shown in the
- // designer canvas" acceptance criterion.
- g.RotateTransform((float)-element.RotationAngle);
- g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
- }
-
- try
- {
- var boxHeight = height * transform.Scale;
- using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
-
- if (element.HasFill && element.FillColor is { } rectFill)
- {
- // Filled rectangle: painted first so the element's own text (and any element with
- // a higher z-order) draws over it, like the real render.
- using var fillBrush = new SolidBrush(System.Drawing.Color.FromArgb(rectFill.R, rectFill.G, rectFill.B));
- g.FillRectangle(fillBrush, (float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);
-
- // Design-view outline so a white (or page-coloured) rectangle is still visible and
- // clickable; drawn only on the canvas, never in the rendered PDF. A dark and a light
- // dashed line together stay visible over any fill or background.
- var outlineRect = new RectangleF((float)drawX, (float)boxTopY, (float)(width * transform.Scale), (float)boxHeight);
- using var darkPen = new Pen(System.Drawing.Color.FromArgb(150, 90, 90, 90), 1f);
- using var lightPen = new Pen(System.Drawing.Color.White, 1f) { DashStyle = DashStyle.Dash };
- g.DrawRectangle(darkPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
- g.DrawRectangle(lightPen, outlineRect.X, outlineRect.Y, outlineRect.Width, outlineRect.Height);
- }
-
- if (element.HasBox)
- {
- // Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own
- // rectangle-bounded DrawString wraps natively within the box, the design-time
- // approximation of the real CLI render's Debenu-native wrap (see this story's
- // sizing note — the two engines have no shared line-breaking code path, so this is
- // an accepted approximation, not a guarantee of matching the exact wrap points).
- // The per-run highlight segmentation below is a single-line layout and does not
- // yet extend across wrapped lines — left as a known, documented visual
- // approximation gap for this story; a per-run-aware wrapped highlight is polish-
- // tier scope for a later story, not required here.
- var boxRect = new RectangleF((float)drawX, (float)drawY, (float)(width * transform.Scale), (float)boxHeight);
- g.DrawString(DisplayTextFor(element), drawFont, brush, boxRect);
-
- // Sprint 10, "Add a live wrap/clip indicator for text elements": a dashed-orange
- // outline, the same visual language as the unmapped-column warning below, drawn
- // whenever the element's *current* content is actually wrapping and/or being
- // clipped — reusing the same box-mode text this method just drew, not a second
- // divergent measurement.
- var (isWrapping, isClipping) = MeasureWrapClip(element, font);
- if (isWrapping || isClipping)
- {
- using var wrapClipPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
- g.DrawRectangle(
- wrapClipPen, (float)drawX - 1, (float)drawY - 1,
- (float)(width * transform.Scale) + 2, (float)boxHeight + 2);
- }
- }
- else
- {
- // Sprint 5, "Mix static text and CSV fields within a single text element", AC4: a
- // visible placeholder highlight per field-run *segment* rather than one
- // whole-element box — literal text within a mixed element gets no fill at all, so
- // an operator can see exactly which portion(s) of the line are field tokens versus
- // literal text. Sprint 4's unmapped-column warning color is now decided per run
- // (see AddressBlockPreviewCalculator's per-run ElementPreviewState.UnmappedRuns)
- // rather than for the whole element, so a mixed element with one bad token among
- // several good ones only flags that one segment.
- var segments = _showData ? new List<(TextRun Run, double Width)>() : MeasureRunSegments(element, font);
- var cumulativeWidth = 0.0;
- for (var i = 0; i < segments.Count; i++)
- {
- var (run, segmentWidth) = segments[i];
- if (run.IsField)
- {
- var isRunUnmapped = i < state.UnmappedRuns.Count && state.UnmappedRuns[i];
- var fillColor = isRunUnmapped
- ? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
- : System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
- using var dynamicFill = new SolidBrush(fillColor);
- var segX = drawX + (cumulativeWidth * transform.Scale);
- var segBoxWidth = segmentWidth * transform.Scale;
- g.FillRectangle(dynamicFill, (float)segX, (float)drawY, (float)segBoxWidth, (float)boxHeight);
- }
-
- cumulativeWidth += segmentWidth;
- }
-
- g.DrawString(DisplayTextFor(element), drawFont, brush, (float)drawX, (float)drawY);
- }
-
- if (state.IsUnmappedColumn)
- {
- using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
- g.DrawRectangle(
- warnPen, (float)drawX - 1, (float)drawY - 1,
- (float)(width * transform.Scale) + 2, (float)(height * transform.Scale) + 2);
- }
- else if (element.CollapseIfBlank)
- {
- // A small marker for a configured-collapsible field, regardless of whether it
- // happens to be blank right now — lets an operator see at a glance which fields
- // are configured to collapse, without needing to select each one individually.
- using var badgePen = new Pen(System.Drawing.Color.SeaGreen, 2);
- var badgeY = (float)(drawY + (height * transform.Scale) + 2);
- var badgeWidth = (float)Math.Min(width * transform.Scale, 16);
- g.DrawLine(badgePen, (float)drawX, badgeY, (float)drawX + badgeWidth, badgeY);
- }
-
- if (isSelected)
- {
- var selWidth = width * transform.Scale;
- var selHeight = height * transform.Scale;
- using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
- g.DrawRectangle(pen, (float)drawX - 2, (float)drawY - 2, (float)selWidth + 4, (float)selHeight + 4);
- }
- }
- finally
- {
- if (savedState is not null)
- {
- g.Restore(savedState);
- }
- }
- }
-
- /// <summary>Post-Sprint-8 user-requested feature: draws the font-size resize handle at the
- /// selected element's top-right corner (<see cref="CanvasElementEditor.ResizeHandlePosition"/>,
- /// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white-
- /// outline style already used for the Address Control's resize handle, deliberately distinct
- /// from the rotate handle's round dot so the two are never confused at a glance.</summary>
- private void DrawHeightHandle(Graphics g, CanvasViewTransform transform)
- {
- var handle = _editor.HeightHandlePosition();
- if (handle is null)
- {
- return;
- }
-
- var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y);
- var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
- using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
- using var outline = new Pen(System.Drawing.Color.White, 1);
- g.FillRectangle(brush, rect);
- g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
- }
-
- private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
- {
- var handle = _editor.ResizeHandlePosition();
- if (handle is null)
- {
- return;
- }
-
- var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y);
- var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
- using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
- using var outline = new Pen(System.Drawing.Color.White, 1);
- g.FillRectangle(brush, rect);
- g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
- }
-
- /// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": draws a small
- /// dot connected to the selected element's bounding-box center by a dotted line, at the
- /// world-space position <see cref="CanvasElementEditor.HandlePosition"/> computes (which
- /// already accounts for the element's current rotation) — the same position
- /// <see cref="CanvasElementEditor.HitTestHandle"/> checks against, so what's drawn is exactly
- /// what's draggable.</summary>
- private void DrawRotateHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
- {
- var handle = _editor.HandlePosition();
- if (handle is null)
- {
- return;
- }
-
- var (width, height) = MeasureElement(element);
- var (pivotX, pivotY) = RotationPivot(element, element.Y, width, height);
- var (centerPx, centerPy) = transform.ToPixels(pivotX, pivotY);
- var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y);
-
- using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
- g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
-
- const float radius = 5f;
- using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
- using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
- g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
- g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
- }
-
- /// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": when
- /// <see cref="AddressControlLayout.RotationAngle"/> is non-zero, the entire method body below
- /// — the box border, every line's text and per-line highlight/selection overlay, and the
- /// resize handle — is drawn under one GDI+ rotation transform around
- /// <see cref="AddressControlLayout.BoxCenter"/>, the same way <see cref="DrawElement"/> already
- /// rotates a standalone element's whole draw call. Because every pixel-space draw call inside
- /// this method (box, lines, handle) shares that one transform, they all visually rotate
- /// together as one rigid unit — matching what <see cref="HitTestAddressControl"/> and
- /// <see cref="HitTestAddressResizeHandle"/> independently confirm by rotating the click point
- /// back into this same unrotated local space before testing.</summary>
- private void DrawAddressControl(
- Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
- {
- GraphicsState? savedState = null;
- if (control.RotationAngle != 0)
- {
- var (pivotX, pivotY) = control.BoxCenter;
- var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
- savedState = g.Save();
- g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
- // See DrawElement's remarks: GDI+'s RotateTransform is visually clockwise-positive in
- // this Y-down pixel space, the opposite of this project's counterclockwise-positive
- // stored convention, hence the negation.
- g.RotateTransform((float)-control.RotationAngle);
- g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
- }
-
- try
- {
- DrawAddressControlUnrotated(g, transform, control, isPrimarySelected, isMultiSelected);
- }
- finally
- {
- if (savedState is not null)
- {
- g.Restore(savedState);
- }
- }
- }
-
- /// <summary>Sprint 10, "Select multiple elements at once on the canvas": <paramref
- /// name="isPrimarySelected"/> and <paramref name="isMultiSelected"/> are deliberately separate
- /// — both draw the same highlighted border (so every selected item is visibly marked), but
- /// only the primary selection gets the resize/rotate handles and the drilled-into-line
- /// highlight, so a multi-selected control never shows handles that would be ambiguous about
- /// which item they act on (out of scope for this story; align/distribute operates on the whole
- /// set instead).</summary>
- private void DrawAddressControlUnrotated(
- Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
- {
- var isHighlighted = isPrimarySelected || isMultiSelected;
- var lineStates = ComputeAddressControlLineStates(control);
- using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash };
- using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1);
-
- var (left, top) = transform.ToPixels(control.X, control.TopBaselineY + MaxLineFontSize(control));
- var (right, bottom) = transform.ToPixels(control.X + control.Width, control.TopBaselineY - control.Height);
- var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
- g.DrawRectangle(isHighlighted ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
- if (isPrimarySelected)
- {
- DrawAddressResizeHandle(g, transform, control);
- // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
- // drawn here, in the control's own local (unrotated) coordinates, so it automatically
- // rotates together with the box/lines via DrawAddressControl's enclosing GDI+
- // transform — the same reason the resize handle above already orbits correctly.
- DrawAddressRotateHandle(g, transform, control);
- }
-
- for (var i = 0; i < control.Lines.Count; i++)
- {
- if (!lineStates[i].Visible)
- {
- continue;
- }
-
- var line = control.Lines[i];
- using var font = ResolveFont(line.FontFamily, (float)line.FontSize);
- var text = control.TextCase.Apply(ResolveAddressLinePreviewText(line) ?? line.DisplayText);
- var size = MeasureText(text, font);
- using var drawFont = ResolveDrawFont(line.FontFamily, (float)line.FontSize, transform.Scale);
- var drawX = transform.ToPixels(control.X, lineStates[i].EffectiveY).X;
- var drawY = transform.ToPixels(control.X, lineStates[i].EffectiveY).Y - BaselineOffsetPx(drawFont);
-
- if (line.IsDynamic && !_showData)
- {
- using var fill = new SolidBrush(System.Drawing.Color.FromArgb(45, System.Drawing.Color.DodgerBlue));
- g.FillRectangle(
- fill,
- (float)drawX,
- (float)drawY,
- (float)Math.Min(control.Width * transform.Scale, Math.Max(6, size.Width * transform.Scale)),
- (float)(size.Height * transform.Scale));
- }
-
- using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B));
- g.DrawString(text, drawFont, brush, (float)drawX, (float)drawY);
-
- if (isPrimarySelected && i == _selectedAddressLineIndex)
- {
- using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1);
- g.DrawRectangle(
- linePen,
- (float)drawX - 2,
- (float)drawY - 2,
- (float)(Math.Max(size.Width, control.Width) * transform.Scale) + 4,
- (float)(size.Height * transform.Scale) + 4);
- }
- }
- }
-
- private static double MaxLineFontSize(AddressControlLayout control) =>
- control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize);
-
- private static void DrawAddressResizeHandle(
- Graphics g, CanvasViewTransform transform, AddressControlLayout control)
- {
- var handle = AddressResizeHandleCenter(control);
- var (handleX, handleY) = transform.ToPixels(handle.X, handle.Y);
- var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
- using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
- using var outline = new Pen(System.Drawing.Color.White, 1);
- g.FillRectangle(brush, rect);
- g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
- }
-
- private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control)
- {
- var top = control.TopBaselineY + MaxLineFontSize(control);
- var bottom = control.TopBaselineY - control.Height;
- return (control.X + control.Width, bottom + ((top - bottom) / 2.0));
- }
-
- /// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
- /// paints the drag handle for a selected Address Control, in the same visual style
- /// (SeaGreen dot, dashed connector line, white outline) as the standalone-element rotate
- /// handle (<see cref="DrawRotateHandle"/>) for consistency — the recommended default per this
- /// story's own notes, absent a strong reason to diverge. Position math lives in the
- /// framework-free, unit-tested <see cref="AddressControlRotateHandle"/> (mirroring how
- /// <see cref="CanvasElementEditor"/> holds the standalone-element equivalent); drawn here in
- /// local (unrotated) coordinates, which is sufficient to make it visually orbit with the
- /// control's own rotation via <see cref="DrawAddressControl"/>'s enclosing GDI+ transform —
- /// the same reason the resize handle above already orbits correctly.</summary>
- private static void DrawAddressRotateHandle(Graphics g, CanvasViewTransform transform, AddressControlLayout control)
- {
- var (centerX, topY) = AddressControlRotateHandle.LocalOrigin(control);
- var (handleX, handleY) = AddressControlRotateHandle.LocalPosition(control);
- var (centerPx, centerPy) = transform.ToPixels(centerX, topY);
- var (handlePx, handlePy) = transform.ToPixels(handleX, handleY);
-
- using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
- g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
-
- const float radius = 5f;
- using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
- using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
- g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
- g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
- }
-
- private AddressLineCollapser.Resolved[] ComputeAddressControlLineStates(AddressControlLayout control)
- {
- var lines = new AddressLineCollapser.Line[control.Lines.Count];
- for (var i = 0; i < control.Lines.Count; i++)
- {
- var line = control.Lines[i];
- var resolvedText = ResolveAddressLinePreviewText(line);
- var shouldCollapse = line.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText);
- lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse);
- }
-
- var resolved = AddressLineCollapser.Resolve(lines).ToArray();
- if (control.VerticalAnchor == AddressVerticalAnchor.Bottom)
- {
- var shift = AddressLineCollapser.BottomAnchorShift(resolved, control.Y);
- for (var i = 0; i < resolved.Length; i++)
- {
- resolved[i] = resolved[i] with { EffectiveY = resolved[i].EffectiveY + shift };
- }
- }
-
- return resolved;
- }
-
- /// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by
- /// <see cref="AddressBlockPreviewCalculator"/> and the new preview panel) instead of keeping
- /// its own identical copy of this per-run resolution rule.</summary>
- private string? ResolveAddressLinePreviewText(AddressControlLineLayout line) =>
- _showData ? TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord) : null;
-
- /// <summary>Sprint 6 defect fix (record-accurate render/preview only, not this canvas — see
- /// below): static text rotates around its measured center, while dynamic/mixed content rotates
- /// around its fixed authored anchor so record-to-record text-width changes cannot move the
- /// pivot. Post-Sprint-7-review fix (2026-10-19): this editing canvas only ever draws an
- /// element's literal `{ColumnName}` token text, never a per-record resolved value (see
- /// <see cref="RotationPivotCalculator.ComputeForCanvasEditing"/>'s remarks), so the dynamic/
- /// mixed-content drift the anchor-pivot branch guards against cannot happen here — this method
- /// now always takes the bounding-box-center path for every element, static or not, so a
- /// dynamic/mixed element's rotate-handle drag spins in place like static text instead of
- /// swinging around a corner. The real render (<c>RotatedTextAnchorCalculator</c>/
- /// <c>DebenuPdfRenderer</c>) and the new preview panel (<c>TemplatePreviewControl</c>/
- /// <c>TemplatePreviewBuilder</c>) are unaffected — they still call
- /// <see cref="RotationPivotCalculator.Compute"/> directly with the real <c>isDynamic</c>
- /// value.</summary>
- private static (double X, double Y) RotationPivot(
- TextElementLayout element, double effectiveY, double width, double height) =>
- RotationPivotCalculator.ComputeForCanvasEditing(element.X, effectiveY, width, height);
-
- /// <summary>Measures an element's rendered size in canvas-space points, using a
- /// <see cref="GraphicsUnit.Point"/> measuring context so the result is directly comparable to
- /// the point-based coordinates <see cref="TextElementLayout"/> stores — this is a design-time
- /// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point
- /// parity with the final PDF. Sprint 9/10, "Add an adjustable width and height with text
- /// wrapping...": once an element has a box (<see cref="TextElementLayout.HasBox"/> — Width
- /// alone, post-Sprint-9), its width is the authored box width, not something to (re-)measure
- /// from text; its height is the explicit clip ceiling when <see cref="TextElementLayout.HasHeightClip"/>
- /// is set, or (when not) the natural wrapped height GDI+ reports for this exact width/text/font
- /// — never a fixed clip in that case. Every downstream consumer of this method (hit-testing,
- /// the rotate handle, the resize handle, and <see cref="DrawElement"/> itself) automatically
- /// treats this as the element's real bounding box with no changes needed at those call sites.</summary>
- private static (double Width, double Height) MeasureElement(TextElementLayout element)
- {
- if (element.HasBox)
- {
- using var boxFont = ResolveFont(element.FontFamily, (float)element.FontSize);
- var effectiveHeight = element.HasHeightClip
- ? element.Height!.Value
- : MeasureWrappedHeight(element.DisplayText, boxFont, element.Width!.Value);
- return (element.Width!.Value, effectiveHeight);
- }
-
- using var bitmap = new Bitmap(1, 1);
- using var g = Graphics.FromImage(bitmap);
- g.PageUnit = GraphicsUnit.Point;
- using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
- var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
- var size = g.MeasureString(text, font);
- return (size.Width, size.Height);
- }
-
- /// <summary>Sprint 9/10: the height GDI+'s own rectangle-bounded layout needs to wrap
- /// <paramref name="text"/> to <paramref name="width"/> points — the auto-height a boxed
- /// element with no explicit <see cref="TextElementLayout.Height"/> clip ceiling uses, mirroring
- /// how the CLI asks Debenu's own <c>GetWrappedTextHeight</c> for the equivalent value.</summary>
- private static double MeasureWrappedHeight(string text, Font font, double width)
- {
- using var bitmap = new Bitmap(1, 1);
- using var g = Graphics.FromImage(bitmap);
- g.PageUnit = GraphicsUnit.Point;
- var displayText = string.IsNullOrEmpty(text) ? " " : text;
- var size = g.MeasureString(displayText, font, Math.Max(1, (int)Math.Round(width)));
- return size.Height;
- }
-
- /// <summary>Sprint 10, "Add a live wrap/clip indicator for text elements": compares the
- /// element's natural (unconstrained) width and GDI+-wrapped height against its box to decide
- /// whether the *current* content is actually wrapping and/or being clipped right now — pure
- /// arithmetic lives in <see cref="WrapClipDetector"/>; this just does the GDI+ measurement
- /// that decision needs.</summary>
- private static (bool IsWrapping, bool IsClipping) MeasureWrapClip(TextElementLayout element, Font font)
- {
- using var bitmap = new Bitmap(1, 1);
- using var g = Graphics.FromImage(bitmap);
- g.PageUnit = GraphicsUnit.Point;
- var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
- var naturalWidth = g.MeasureString(text, font).Width;
- var wrappedHeight = MeasureWrappedHeight(text, font, element.Width!.Value);
-
- // No explicit clip ceiling means "no clip" by definition (see HasHeightClip's remarks) —
- // never flag clipping in that case, regardless of how tall the wrapped content grows.
- var boxHeight = element.HasHeightClip ? element.Height!.Value : double.PositiveInfinity;
- return WrapClipDetector.Detect(naturalWidth, wrappedHeight, element.Width!.Value, boxHeight);
- }
-
- private static (double Width, double Height) MeasureText(string text, Font font)
- {
- using var bitmap = new Bitmap(1, 1);
- using var g = Graphics.FromImage(bitmap);
- g.PageUnit = GraphicsUnit.Point;
- var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font);
- return (size.Width, size.Height);
- }
-
- /// <summary>Sprint 5, AC4: measures each run's own display segment width in canvas-space
- /// points (same <see cref="GraphicsUnit.Point"/> measuring context as <see cref="MeasureElement"/>,
- /// for consistency), so <see cref="DrawElement"/> can position a per-run highlight fill at the
- /// right cumulative offset. Like <see cref="MeasureElement"/>, this is a design-time visual
- /// approximation — GDI+'s per-segment measurement summed this way does not necessarily equal
- /// its measurement of the whole concatenated string to the last fraction of a point (kerning/
- /// spacing metrics are not strictly additive), which is an accepted, documented limitation of
- /// a canvas preview rather than a rendering-affecting one (the CLI's real render always draws
- /// one already-concatenated string, never per-run pieces).</summary>
- private static IReadOnlyList<(TextRun Run, double Width)> MeasureRunSegments(TextElementLayout element, Font font)
- {
- using var bitmap = new Bitmap(1, 1);
- using var g = Graphics.FromImage(bitmap);
- g.PageUnit = GraphicsUnit.Point;
-
- var segments = TextRunTextConverter.ToDisplaySegments(element.Runs);
- var result = new List<(TextRun, double)>(segments.Count);
- foreach (var (run, text) in segments)
- {
- var width = text.Length == 0 ? 0.0 : g.MeasureString(text, font).Width;
- result.Add((run, width));
- }
-
- return result;
- }
-
- /// <summary>Falls back to a generic sans-serif font if the requested family isn't installed,
- /// so a missing font only affects the design-time preview's appearance — it does not crash
- /// the designer. This is independent of, and does not relax, the render-time rule that a
- /// missing font is a blocking error (`TEMPLATE_FORMAT.md`'s "Known gaps").</summary>
- /// <summary>The font used to actually paint text: sized in pixels (point size times the
- /// canvas zoom) so glyphs scale with the page exactly like positions and boxes do. A
- /// points-unit font would be converted at the screen DPI regardless of zoom, so text would
- /// not track the PDF's size. Measurement helpers keep using <see cref="ResolveFont"/> (points).</summary>
- private static Font ResolveDrawFont(string familyName, float sizePoints, double scale)
- {
- var px = (float)(Math.Max(sizePoints <= 0 ? 12f : sizePoints, 0.1f) * scale);
- try
- {
- return new Font(familyName, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel);
- }
- catch (ArgumentException)
- {
- return new Font(FontFamily.GenericSansSerif, Math.Max(px, 1f), FontStyle.Regular, GraphicsUnit.Pixel);
- }
- }
-
- /// <summary>Distance in pixels from the top of a GDI+ text box to the baseline for a
- /// pixel-unit font. The PDF places text by baseline (the element's Y), so the canvas draws the
- /// box this far above the baseline.</summary>
- private static float BaselineOffsetPx(Font pixelFont)
- {
- var family = pixelFont.FontFamily;
- var style = pixelFont.Style;
- return pixelFont.Size * family.GetCellAscent(style) / family.GetEmHeight(style);
- }
-
- private static Font ResolveFont(string familyName, float size)
- {
- try
- {
- return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point);
- }
- catch (ArgumentException)
- {
- return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point);
- }
- }
-
- protected override void OnMouseDown(MouseEventArgs e)
- {
- base.OnMouseDown(e);
- // Post-Sprint-9: needed so this control actually receives OnKeyDown (Delete key) at all —
- // see the constructor's ControlStyles.Selectable remarks.
- Focus();
- if (e.Button != MouseButtons.Left || _showData)
- {
- return;
- }
-
- // Post-Sprint-10 user-reported regression fix: SelectionChanged used to fire unconditionally
- // on every left-click, including a click on an item that was ALREADY selected (the normal
- // way to begin dragging it). TemplateDesignerForm's SelectionChanged handler runs the full,
- // non-trivial RefreshPropertiesPanel() (combo-box rebind repopulation etc.) with no
- // IsInteracting-style gating the way the drag/rotate/resize ElementsChanged path has, so
- // every single click paid that cost — a real, measured ~40-60ms hitch per click once this
- // session's added property-panel rows (box width/height, delete button) made the refresh
- // heavy enough to notice. Capturing the pre-click selection identity and only raising the
- // event when it actually changed makes "click an already-selected element to move it" free
- // again while still notifying on every real selection change.
- var previousSelectedElement = _editor.Selected;
- var previousSelectedAddressControl = _selectedAddressControl;
- var previousSelectedAddressLineIndex = _selectedAddressLineIndex;
-
- // Sprint 10, "Select multiple elements at once on the canvas": Ctrl or Shift held during
- // the click means "toggle this one item's membership in the multi-selection" (or, for an
- // empty-space click below, "add to the multi-selection via rubber-band" rather than
- // replacing it) — checked once up front since every hit-test branch below needs it.
- var isModifierClick = (ModifierKeys & (Keys.Control | Keys.Shift)) != 0;
-
- var transform = CurrentTransform();
- var (x, y) = transform.ToPoints(e.X, e.Y);
-
- if (_selectedAddressControl is not null && HitTestAddressResizeHandle(_selectedAddressControl, x, y, transform))
- {
- _isResizingAddressControl = true;
- Capture = true;
- Invalidate();
- return;
- }
-
- // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": checked
- // right alongside the resize-handle check above (same precedence rule: only meaningful,
- // and only checked, when this control is already selected) so a rotate-handle grab is
- // never confused with a normal select/move click elsewhere on the control.
- if (_selectedAddressControl is not null && AddressControlRotateHandle.HitTest(_selectedAddressControl, x, y))
- {
- _isRotatingAddressControl = true;
- Capture = true;
- Invalidate();
- return;
- }
-
- var addressHit = HitTestAddressControl(x, y);
- if (addressHit.Control is not null)
- {
- if (isModifierClick)
- {
- ToggleAddressControlMultiSelect(addressHit.Control);
- Invalidate();
- RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
- return;
- }
-
- if (IsMultiSelectionActive && _multiSelectedAddressControls.Contains(addressHit.Control))
- {
- // Clicking a member of an existing multi-selection (no modifier) starts a group
- // drag of the whole set instead of collapsing back to a single selection — the
- // same "click an already-selected item to move it" gesture single-select has
- // always supported, extended to the group.
- BeginGroupDrag(x, y);
- Capture = true;
- Invalidate();
- return;
- }
-
- ClearMultiSelection();
- SelectAddressControl(addressHit.Control, addressHit.LineIndex);
- _addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y);
- Capture = true;
- Invalidate();
- RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
- return;
- }
-
- ClearAddressSelection();
-
- // Post-Sprint-8 user-requested feature: a click on the selected element's font-size
- // resize handle starts a resize-drag, checked with the same precedence as the rotate
- // handle immediately below (only meaningful, and only checked, once something is already
- // selected) so it is never confused with a normal select/move click.
- if (_editor.HitTestHeightHandle(x, y))
- {
- _editor.BeginHeightResizeDrag();
- Capture = true;
- Invalidate();
- return;
- }
-
- if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y))
- {
- _editor.BeginResizeDrag();
- Capture = true;
- Invalidate();
- return;
- }
-
- // Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag
- // instead of a normal select/move — checked first (and only when something is already
- // selected) so it never intercepts a normal click elsewhere on the canvas.
- if (_editor.Selected is not null && _editor.HitTestHandle(x, y))
- {
- _editor.BeginRotateDrag();
- Capture = true;
- Invalidate();
- return;
- }
-
- var hitElement = _editor.HitTest(x, y);
- if (hitElement is not null)
- {
- if (isModifierClick)
- {
- ToggleElementMultiSelect(hitElement);
- Invalidate();
- RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
- return;
- }
-
- if (IsMultiSelectionActive && _editor.MultiSelected.Contains(hitElement))
- {
- BeginGroupDrag(x, y);
- Capture = true;
- Invalidate();
- return;
- }
-
- ClearMultiSelection();
- _editor.Select(hitElement);
- _editor.BeginDrag(x, y);
- Capture = true;
- Invalidate();
- RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
- return;
- }
-
- // Nothing hit: start a rubber-band selection rather than just clearing the selection — a
- // zero-size drag (a plain click with no movement) naturally selects nothing on release,
- // reproducing the old "click empty space to deselect" behavior without special-casing it.
- if (!isModifierClick)
- {
- ClearMultiSelection();
- }
-
- _editor.Select(null);
- _isRubberBandSelecting = true;
- _rubberBandAdditive = isModifierClick;
- _rubberBandStart = (x, y);
- _rubberBandCurrent = (x, y);
- Capture = true;
- Invalidate();
- RaiseSelectionChangedIfDifferent(previousSelectedElement, previousSelectedAddressControl, previousSelectedAddressLineIndex);
- }
-
- /// <summary>Raises <see cref="SelectionChanged"/> only when the selection identity captured
- /// before a click actually differs from the current one — see <see cref="OnMouseDown"/>'s
- /// remarks for why an unconditional raise on every click was a real, measured hitch.</summary>
- private void RaiseSelectionChangedIfDifferent(
- TextElementLayout? previousElement, AddressControlLayout? previousAddressControl, int previousAddressLineIndex)
- {
- if (!ReferenceEquals(previousElement, _editor.Selected)
- || !ReferenceEquals(previousAddressControl, _selectedAddressControl)
- || previousAddressLineIndex != _selectedAddressLineIndex)
- {
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- }
- }
-
- /// <summary>Sprint 8: a rotated control must remain correctly click-selectable at its actual
- /// rotated position, not its unrotated bounding box — ported from
- /// <c>CanvasElementEditor.IsPointInRotatedBounds</c>'s approach: rotate the click point
- /// backward (by <c>-RotationAngle</c>) around the same <see cref="AddressControlLayout.BoxCenter"/>
- /// pivot <see cref="DrawAddressControl"/> rotates around, landing it back in the control's
- /// unrotated local space, then run the exact same plain-rectangle/line test as before. An
- /// unrotated control (the overwhelmingly common case) takes the cheap direct path.</summary>
- private (AddressControlLayout? Control, int LineIndex) HitTestAddressControl(double xPoints, double yPoints)
- {
- foreach (var control in _document.AddressControls.OrderByDescending(c => c.ZOrder))
- {
- var (testX, testY) = control.RotationAngle == 0
- ? (xPoints, yPoints)
- : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
-
- var top = control.TopBaselineY + MaxLineFontSize(control);
- var bottom = control.TopBaselineY - control.Height;
- if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top)
- {
- continue;
- }
-
- var lineIndex = 0;
- for (var i = 0; i < control.Lines.Count; i++)
- {
- var baseline = control.BaselineYForLine(i);
- var lineTop = baseline + control.Lines[i].FontSize;
- var nextBaseline = i == control.Lines.Count - 1
- ? bottom
- : control.BaselineYForLine(i + 1);
- if (testY <= lineTop && testY >= nextBaseline)
- {
- lineIndex = i;
- break;
- }
- }
-
- return (control, lineIndex);
- }
-
- return (null, 0);
- }
-
- /// <summary>Sprint 8: same rotate-the-click-point-backward approach as
- /// <see cref="HitTestAddressControl"/> — the resize handle is drawn (via
- /// <see cref="DrawAddressResizeHandle"/>) inside <see cref="DrawAddressControl"/>'s rotation
- /// transform, so it visually orbits with the rotated box; the hit test rotates the click point
- /// back into the same unrotated local space before comparing against the handle's unrotated
- /// position.</summary>
- private static bool HitTestAddressResizeHandle(
- AddressControlLayout control, double xPoints, double yPoints, CanvasViewTransform transform)
- {
- const double handleTolerancePixels = 8;
- var tolerancePoints = handleTolerancePixels / transform.Scale;
- var (testX, testY) = control.RotationAngle == 0
- ? (xPoints, yPoints)
- : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
- var handle = AddressResizeHandleCenter(control);
- return Math.Abs(testX - handle.X) <= tolerancePoints
- && Math.Abs(testY - handle.Y) <= tolerancePoints;
- }
-
- protected override void OnMouseMove(MouseEventArgs e)
- {
- base.OnMouseMove(e);
-
- // Checked before the IsInteracting gate below: a rubber-band drag doesn't change any
- // element's data (so it never needs ElementsChanged/the property-panel refresh that gate
- // exists to guard), it only needs the marquee rectangle to repaint on every tick.
- if (_isRubberBandSelecting)
- {
- var (rbX, rbY) = CurrentTransform().ToPoints(e.X, e.Y);
- _rubberBandCurrent = (rbX, rbY);
- Invalidate();
- return;
- }
-
- if (!IsInteracting)
- {
- return;
- }
-
- var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
-
- if (_editor.IsMultiDragging)
- {
- _editor.MultiDragTo(x, y);
- ApplyMultiDragToAddressControls(x, y);
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return;
- }
-
- if (_selectedAddressControl is not null && _isRotatingAddressControl)
- {
- AddressControlRotateHandle.RotateDragTo(_selectedAddressControl, x, y);
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return;
- }
-
- if (_selectedAddressControl is not null && _isResizingAddressControl)
- {
- // Sprint 8: rotate the drag point backward into the control's unrotated local space
- // first (same as the hit test above) so resizing a rotated control still tracks the
- // mouse along the box's own local width axis, not the world X axis.
- var (localX, _) = _selectedAddressControl.RotationAngle == 0
- ? (x, y)
- : PointRotation.RotateAroundPivot(x, y, _selectedAddressControl.BoxCenter, -_selectedAddressControl.RotationAngle);
- var width = Math.Max(1, localX - _selectedAddressControl.X);
- _selectedAddressControl.Width = SnapToGridEnabled ? Math.Max(1, GridSnapper.Snap(width, GridSizePoints)) : width;
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return;
- }
-
- if (_selectedAddressControl is not null && _addressControlDragOffset is not null)
- {
- var newX = x - _addressControlDragOffset.Value.Dx;
- var newY = y - _addressControlDragOffset.Value.Dy;
- if (SnapToGridEnabled)
- {
- newX = GridSnapper.Snap(newX, GridSizePoints);
- newY = GridSnapper.Snap(newY, GridSizePoints);
- }
-
- _selectedAddressControl.X = newX;
- _selectedAddressControl.Y = newY;
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- return;
- }
-
- if (_editor.IsResizing)
- {
- _editor.ResizeDragTo(x, y);
- }
- else if (_editor.IsRotating)
- {
- _editor.RotateDragTo(x, y);
- }
- else
- {
- _editor.DragTo(x, y);
- }
-
- Invalidate();
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
-
- protected override void OnMouseUp(MouseEventArgs e)
- {
- base.OnMouseUp(e);
-
- if (_isRubberBandSelecting)
- {
- Capture = false;
- FinalizeRubberBandSelection();
- return;
- }
-
- var wasInteracting = IsInteracting;
- _editor.EndDrag();
- EndGroupDrag();
- _addressControlDragOffset = null;
- _isResizingAddressControl = false;
- _isRotatingAddressControl = false;
- Capture = false;
-
- // The gesture's own per-tick ElementsChanged notifications (OnMouseMove) only ask
- // TemplateDesignerForm for a cheap position/angle-only sync (see IsInteracting's
- // remarks) — fire one last notification now that the gesture has actually ended so the
- // form's full properties-panel refresh (rebind combo, address line list, etc.) still
- // runs exactly once at the end, the same as it always did before this smoothness fix.
- if (wasInteracting)
- {
- ElementsChanged?.Invoke(this, EventArgs.Empty);
- }
- }
-
- /// <summary>Post-Sprint-9 user-reported gap: Delete/Backspace removes whichever element or
- /// Address Control is currently selected — there was no way to delete a placed item at all
- /// before this. Only fires while not mid-gesture, so an accidental key press during a drag
- /// can't delete out from under an in-progress move/resize/rotate.</summary>
- protected override void OnKeyDown(KeyEventArgs e)
- {
- base.OnKeyDown(e);
- if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting && !_showData)
- {
- RemoveSelectedElement();
- e.Handled = true;
- }
- else if (e.KeyCode == Keys.Tab && !e.Control && !e.Alt)
- {
- // Tab = next item, Shift+Tab = previous (z-order, wrapping) - reaches items hidden
- // behind other items. Ctrl+Tab still leaves the canvas as usual.
- SelectNextItem(forward: !e.Shift);
- e.Handled = true;
- e.SuppressKeyPress = true;
- }
- }
-
- /// <summary>Selects the next (or previous) item in z-order, so an item hidden behind another
- /// one can be reached without clicking it. Wraps at both ends. Returns <c>false</c> when there
- /// is nothing to select or the canvas is showing data (selection is disabled then).</summary>
- public bool SelectNextItem(bool forward)
- {
- if (_showData || IsInteracting)
- {
- return false;
- }
-
- var items = _document.Elements
- .Select(e => new SelectionCycler.Item(e.Id, e.ZOrder, e.X, e.Y))
- .Concat(_document.AddressControls.Select(c => new SelectionCycler.Item(c.Id, c.ZOrder, c.X, c.Y)))
- .ToList();
- Guid? current = _editor.Selected?.Id ?? _selectedAddressControl?.Id;
- if (SelectionCycler.Next(items, current, forward) is not { } nextId)
- {
- return false;
- }
-
- ClearMultiSelection();
- ClearAddressSelection();
- var element = _document.Elements.FirstOrDefault(e => e.Id == nextId);
- if (element is not null)
- {
- _editor.Select(element);
- }
- else if (_document.AddressControls.FirstOrDefault(c => c.Id == nextId) is { } control)
- {
- SelectAddressControl(control, 0);
- }
-
- Invalidate();
- SelectionChanged?.Invoke(this, EventArgs.Empty);
- return true;
- }
-
- protected override bool IsInputKey(Keys keyData) =>
- keyData is Keys.Delete or Keys.Back
- || ((keyData & ~Keys.Shift) == Keys.Tab)
- || base.IsInputKey(keyData);
- }
|