You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1014 lines
48KB

  1. using System.Drawing.Drawing2D;
  2. using EnvelopeRenderer.Desktop.Core.Design;
  3. namespace EnvelopeRenderer.Desktop.Views;
  4. /// <summary>
  5. /// The visual canvas surface (Sprint 2 Batch 3: "Place and move text elements on the canvas"):
  6. /// draws the page and its text elements, and lets the operator select/drag-reposition them with
  7. /// the mouse. Sprint 4 added: address-line collapse preview (so the canvas visually agrees with
  8. /// what the CLI would render for a loaded CSV's sample data), rotation (drawing a rotated element
  9. /// and hit-testing/dragging its rotate handle), and a mapping-error highlight for a dynamic
  10. /// element bound to a column that isn't in the loaded CSV. All coordinate math and add/select/
  11. /// drag/rotate state live in <see cref="CanvasElementEditor"/>/<see cref="CanvasViewTransform"/>/
  12. /// <see cref="AddressBlockPreviewCalculator"/> (all framework-free and unit tested in
  13. /// EnvelopeRenderer.Desktop.Tests) — this class only does the GDI+ drawing and forwards mouse
  14. /// events, since that part genuinely cannot be extracted from WinForms.
  15. /// </summary>
  16. public sealed class TemplateCanvasControl : Control
  17. {
  18. private readonly TemplateLayoutDocument _document;
  19. private readonly CanvasElementEditor _editor;
  20. private AddressControlLayout? _selectedAddressControl;
  21. private int _selectedAddressLineIndex;
  22. private (double Dx, double Dy)? _addressControlDragOffset;
  23. private bool _isResizingAddressControl;
  24. /// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
  25. /// mirrors <see cref="_isResizingAddressControl"/>'s shape — a control-specific drag-gesture
  26. /// flag, paralleling (not reusing) <see cref="CanvasElementEditor.IsRotating"/>, which only
  27. /// ever operates on a selected standalone <see cref="TextElementLayout"/>.</summary>
  28. private bool _isRotatingAddressControl;
  29. /// <summary>The currently loaded CSV's headers and one representative sample record, used
  30. /// only to preview address-line collapsing and mapping-error highlighting (Sprint 4) —
  31. /// empty/<c>null</c> until <see cref="SetCsvPreviewContext"/> is called after a CSV loads.</summary>
  32. private IReadOnlyList<string> _csvHeaders = Array.Empty<string>();
  33. private IReadOnlyDictionary<string, string>? _csvSampleRecord;
  34. public event EventHandler? SelectionChanged;
  35. public event EventHandler? ElementsChanged;
  36. public TemplateCanvasControl(TemplateLayoutDocument document)
  37. {
  38. _document = document;
  39. _editor = new CanvasElementEditor(document, MeasureElement);
  40. DoubleBuffered = true;
  41. BackColor = SystemColors.ControlDark;
  42. SetStyle(ControlStyles.ResizeRedraw, true);
  43. }
  44. /// <summary>Sprint 7, "Snap elements to grid and guides": mirrors
  45. /// <see cref="CanvasElementEditor.SnapToGridEnabled"/> so the same toggle governs standalone
  46. /// element dragging (handled inside <see cref="CanvasElementEditor"/>) and Address Control
  47. /// move/resize (handled directly in this control's mouse handlers below) uniformly, and so
  48. /// this control knows whether to paint grid lines.</summary>
  49. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
  50. public bool SnapToGridEnabled
  51. {
  52. get => _editor.SnapToGridEnabled;
  53. set
  54. {
  55. _editor.SnapToGridEnabled = value;
  56. Invalidate();
  57. }
  58. }
  59. /// <summary>Sprint 7: the grid increment (canvas-space points) snapping rounds to and grid
  60. /// lines are painted at, when <see cref="SnapToGridEnabled"/> is <c>true</c>.</summary>
  61. [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
  62. public double GridSizePoints
  63. {
  64. get => _editor.GridSizePoints;
  65. set
  66. {
  67. _editor.GridSizePoints = value > 0 ? value : CanvasElementEditor.DefaultGridSizePoints;
  68. Invalidate();
  69. }
  70. }
  71. /// <summary>True while an active move, resize, or rotate gesture is in progress on the
  72. /// canvas — a standalone element drag/rotate (<see cref="CanvasElementEditor"/>) or an
  73. /// Address Control move/resize/rotate. Post-Sprint-8 smoothness fix: lets
  74. /// <see cref="TemplateDesignerForm"/> skip its full properties-panel refresh (which
  75. /// repopulates the rebind-column combo box from every loaded CSV header on each call — real
  76. /// work only for a single-column-bound dynamic element) on every mouse-move tick of a
  77. /// gesture, syncing just the position/angle fields the gesture can actually change instead.
  78. /// Mirrors the exact condition <see cref="OnMouseMove"/> already used inline before this
  79. /// property existed.</summary>
  80. public bool IsInteracting =>
  81. _editor.IsDragging || _editor.IsRotating || _editor.IsResizingFontSize || _addressControlDragOffset is not null
  82. || _isResizingAddressControl || _isRotatingAddressControl;
  83. public TextElementLayout? SelectedElement => _editor.Selected;
  84. public AddressControlLayout? SelectedAddressControl => _selectedAddressControl;
  85. public int SelectedAddressLineIndex => _selectedAddressLineIndex;
  86. public AddressControlLineLayout? SelectedAddressLine =>
  87. _selectedAddressControl is not null
  88. && _selectedAddressLineIndex >= 0
  89. && _selectedAddressLineIndex < _selectedAddressControl.Lines.Count
  90. ? _selectedAddressControl.Lines[_selectedAddressLineIndex]
  91. : null;
  92. public TextElementLayout AddStaticTextElement()
  93. {
  94. var (x, y) = DefaultNewElementPosition();
  95. var element = _editor.AddStaticText(x, y);
  96. Invalidate();
  97. SelectionChanged?.Invoke(this, EventArgs.Empty);
  98. ElementsChanged?.Invoke(this, EventArgs.Empty);
  99. return element;
  100. }
  101. public AddressControlLayout AddAddressControl()
  102. {
  103. var (x, y) = DefaultNewElementPosition();
  104. var control = AddressControlLayout.CreateDefault(x, y, _document.NextZOrder());
  105. _document.AddressControls.Add(control);
  106. SelectAddressControl(control, 0);
  107. Invalidate();
  108. SelectionChanged?.Invoke(this, EventArgs.Empty);
  109. ElementsChanged?.Invoke(this, EventArgs.Empty);
  110. return control;
  111. }
  112. public void AddAddressLine()
  113. {
  114. if (_selectedAddressControl is null)
  115. {
  116. return;
  117. }
  118. _selectedAddressControl.AddLine();
  119. _selectedAddressLineIndex = _selectedAddressControl.Lines.Count - 1;
  120. Invalidate();
  121. SelectionChanged?.Invoke(this, EventArgs.Empty);
  122. ElementsChanged?.Invoke(this, EventArgs.Empty);
  123. }
  124. public void RemoveSelectedAddressLine()
  125. {
  126. if (_selectedAddressControl is null)
  127. {
  128. return;
  129. }
  130. if (_selectedAddressControl.RemoveLineAt(_selectedAddressLineIndex))
  131. {
  132. _selectedAddressLineIndex = Math.Min(_selectedAddressLineIndex, _selectedAddressControl.Lines.Count - 1);
  133. Invalidate();
  134. SelectionChanged?.Invoke(this, EventArgs.Empty);
  135. ElementsChanged?.Invoke(this, EventArgs.Empty);
  136. }
  137. }
  138. public void SelectAddressLine(int lineIndex)
  139. {
  140. if (_selectedAddressControl is null)
  141. {
  142. return;
  143. }
  144. _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, _selectedAddressControl.Lines.Count - 1));
  145. Invalidate();
  146. SelectionChanged?.Invoke(this, EventArgs.Empty);
  147. }
  148. public void MoveSelectedAddressLineUp()
  149. {
  150. if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineUp(_selectedAddressLineIndex))
  151. {
  152. _selectedAddressLineIndex--;
  153. Invalidate();
  154. SelectionChanged?.Invoke(this, EventArgs.Empty);
  155. ElementsChanged?.Invoke(this, EventArgs.Empty);
  156. }
  157. }
  158. public void MoveSelectedAddressLineDown()
  159. {
  160. if (_selectedAddressControl is not null && _selectedAddressControl.MoveLineDown(_selectedAddressLineIndex))
  161. {
  162. _selectedAddressLineIndex++;
  163. Invalidate();
  164. SelectionChanged?.Invoke(this, EventArgs.Empty);
  165. ElementsChanged?.Invoke(this, EventArgs.Empty);
  166. }
  167. }
  168. public TextElementLayout AddDynamicPlaceholderElement(string columnName = "Column")
  169. {
  170. var (x, y) = DefaultNewElementPosition();
  171. var element = _editor.AddDynamicPlaceholder(x, y, columnName);
  172. Invalidate();
  173. SelectionChanged?.Invoke(this, EventArgs.Empty);
  174. ElementsChanged?.Invoke(this, EventArgs.Empty);
  175. return element;
  176. }
  177. /// <summary>Sprint 4: supplies the loaded CSV's headers and one representative sample record
  178. /// (typically the first loaded sample row) so the canvas can preview address-line collapsing
  179. /// and flag mapping errors exactly the way a real render would. Pass an empty header list and
  180. /// <c>null</c> record to clear the preview context (e.g. nothing loaded yet).</summary>
  181. public void SetCsvPreviewContext(IReadOnlyList<string> headers, IReadOnlyDictionary<string, string>? sampleRecord)
  182. {
  183. _csvHeaders = headers;
  184. _csvSampleRecord = sampleRecord;
  185. Invalidate();
  186. }
  187. /// <summary>Re-selects the given element (e.g. after the properties panel changes it) and
  188. /// redraws — used so external edits stay visually in sync with the canvas.</summary>
  189. public void NotifyElementChanged()
  190. {
  191. Invalidate();
  192. ElementsChanged?.Invoke(this, EventArgs.Empty);
  193. }
  194. /// <summary>Clears the current selection and redraws — used after reopening a saved template
  195. /// (Batch 5), since a freshly loaded document's elements are new object instances and any
  196. /// previously selected element instance no longer belongs to it.</summary>
  197. public void ClearSelection()
  198. {
  199. _editor.Select(null);
  200. _selectedAddressControl = null;
  201. _selectedAddressLineIndex = 0;
  202. _addressControlDragOffset = null;
  203. _isResizingAddressControl = false;
  204. _isRotatingAddressControl = false;
  205. Invalidate();
  206. SelectionChanged?.Invoke(this, EventArgs.Empty);
  207. }
  208. private (double X, double Y) DefaultNewElementPosition()
  209. {
  210. // Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
  211. // on top of the last one.
  212. var count = _document.Elements.Count + _document.AddressControls.Count;
  213. var x = Math.Min(_document.Canvas.WidthPoints * 0.1 + (count * 10), _document.Canvas.WidthPoints - 20);
  214. var y = Math.Max(_document.Canvas.HeightPoints * 0.8 - (count * 10), 10);
  215. return (x, y);
  216. }
  217. private CanvasViewTransform CurrentTransform() =>
  218. CanvasViewTransform.Fit(_document.Canvas.WidthPoints, _document.Canvas.HeightPoints, ClientSize.Width, ClientSize.Height);
  219. protected override void OnPaint(PaintEventArgs e)
  220. {
  221. base.OnPaint(e);
  222. var g = e.Graphics;
  223. g.SmoothingMode = SmoothingMode.AntiAlias;
  224. g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
  225. var transform = CurrentTransform();
  226. var (pageLeft, pageTop) = transform.ToPixels(0, _document.Canvas.HeightPoints);
  227. var (pageRight, pageBottom) = transform.ToPixels(_document.Canvas.WidthPoints, 0);
  228. var pageRect = RectangleF.FromLTRB((float)pageLeft, (float)pageTop, (float)pageRight, (float)pageBottom);
  229. g.FillRectangle(Brushes.White, pageRect);
  230. g.DrawRectangle(Pens.Black, pageRect.X, pageRect.Y, pageRect.Width, pageRect.Height);
  231. // Sprint 7, "Snap elements to grid and guides": paint the grid while snap is enabled so
  232. // alignment is visible, not just felt during a drag — drawn under every element so it
  233. // never obscures selection/rotate-handle/mapping-warning visuals.
  234. if (SnapToGridEnabled)
  235. {
  236. DrawGrid(g, transform);
  237. }
  238. // Sprint 4: the same collapse-then-shift math the CLI's RenderEngine applies at render
  239. // time, run here against the loaded CSV's sample record so the canvas preview and the
  240. // final PDF agree (the story's "Preview and final render must agree" conversation note).
  241. var previewStates = AddressBlockPreviewCalculator.Compute(_document.Elements, _csvHeaders, _csvSampleRecord);
  242. var paintItems = new List<(int ZOrder, TextElementLayout? Text, AddressControlLayout? Control)>();
  243. paintItems.AddRange(_document.Elements.Select(e => (e.ZOrder, Text: (TextElementLayout?)e, Control: (AddressControlLayout?)null)));
  244. paintItems.AddRange(_document.AddressControls.Select(c => (c.ZOrder, Text: (TextElementLayout?)null, Control: (AddressControlLayout?)c)));
  245. foreach (var item in paintItems.OrderBy(i => i.ZOrder))
  246. {
  247. if (item.Text is not null)
  248. {
  249. var state = previewStates[item.Text.Id];
  250. if (!state.Visible)
  251. {
  252. continue;
  253. }
  254. DrawElement(g, transform, item.Text, state, isSelected: ReferenceEquals(item.Text, _editor.Selected));
  255. continue;
  256. }
  257. DrawAddressControl(
  258. g,
  259. transform,
  260. item.Control!,
  261. isSelected: ReferenceEquals(item.Control, _selectedAddressControl));
  262. }
  263. if (_editor.Selected is not null)
  264. {
  265. DrawResizeHandle(g, transform, _editor.Selected);
  266. DrawRotateHandle(g, transform, _editor.Selected);
  267. }
  268. }
  269. /// <summary>Sprint 7: draws light dotted grid lines across the page at every
  270. /// <see cref="GridSizePoints"/> interval, in both directions, so an operator can see the
  271. /// alignment grid snapping is rounding positions to.</summary>
  272. private void DrawGrid(Graphics g, CanvasViewTransform transform)
  273. {
  274. var gridSize = GridSizePoints;
  275. if (gridSize <= 0)
  276. {
  277. return;
  278. }
  279. using var gridPen = new Pen(System.Drawing.Color.FromArgb(110, System.Drawing.Color.SteelBlue), 1)
  280. {
  281. DashStyle = DashStyle.Dot,
  282. };
  283. for (var x = 0.0; x <= _document.Canvas.WidthPoints; x += gridSize)
  284. {
  285. var (x1, y1) = transform.ToPixels(x, 0);
  286. var (x2, y2) = transform.ToPixels(x, _document.Canvas.HeightPoints);
  287. g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
  288. }
  289. for (var y = 0.0; y <= _document.Canvas.HeightPoints; y += gridSize)
  290. {
  291. var (x1, y1) = transform.ToPixels(0, y);
  292. var (x2, y2) = transform.ToPixels(_document.Canvas.WidthPoints, y);
  293. g.DrawLine(gridPen, (float)x1, (float)y1, (float)x2, (float)y2);
  294. }
  295. }
  296. private void SelectAddressControl(AddressControlLayout control, int lineIndex)
  297. {
  298. _selectedAddressControl = control;
  299. _selectedAddressLineIndex = Math.Max(0, Math.Min(lineIndex, control.Lines.Count - 1));
  300. _editor.Select(null);
  301. }
  302. private void ClearAddressSelection()
  303. {
  304. _selectedAddressControl = null;
  305. _selectedAddressLineIndex = 0;
  306. _addressControlDragOffset = null;
  307. _isResizingAddressControl = false;
  308. _isRotatingAddressControl = false;
  309. }
  310. private void DrawElement(
  311. Graphics g, CanvasViewTransform transform, TextElementLayout element,
  312. ElementPreviewState state, bool isSelected)
  313. {
  314. var effectiveY = state.EffectiveY;
  315. using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
  316. var (width, height) = MeasureElement(element);
  317. // Element (X, effectiveY) is the bottom-left, baseline-ish origin in canvas space (points,
  318. // bottom-left page origin); the drawn box spans up to (X + width, effectiveY + height), so
  319. // the pixel position to draw the string's top-left corner at is the transform of
  320. // (X, effectiveY + height). `effectiveY` is the same as `element.Y` unless Sprint 4's
  321. // address-line collapsing has shifted it (see AddressBlockPreviewCalculator).
  322. var (drawX, drawY) = transform.ToPixels(element.X, effectiveY + height);
  323. GraphicsState? savedState = null;
  324. if (element.RotationAngle != 0)
  325. {
  326. var (pivotX, pivotY) = RotationPivot(element, effectiveY, width, height);
  327. var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
  328. savedState = g.Save();
  329. g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
  330. // GDI+'s Graphics.RotateTransform is visually CLOCKWISE for a positive angle in this
  331. // Y-down pixel space. The stored RotationAngle uses the opposite convention —
  332. // counterclockwise-positive, confirmed empirically against the real Debenu DLL (see
  333. // RotatedTextAnchorCalculator's class remarks in EnvelopeRenderer.Cli) — so the angle
  334. // is negated here to keep the canvas rotating the same visual direction the final PDF
  335. // will, per this story's "same rotation, around the same pivot, as shown in the
  336. // designer canvas" acceptance criterion.
  337. g.RotateTransform((float)-element.RotationAngle);
  338. g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
  339. }
  340. try
  341. {
  342. var boxHeight = height * transform.Scale;
  343. using var brush = new SolidBrush(System.Drawing.Color.FromArgb(element.Color.R, element.Color.G, element.Color.B));
  344. if (element.HasBox)
  345. {
  346. // Sprint 9, "Add an adjustable width and height with text wrapping...": GDI+'s own
  347. // rectangle-bounded DrawString wraps natively within the box, the design-time
  348. // approximation of the real CLI render's Debenu-native wrap (see this story's
  349. // sizing note — the two engines have no shared line-breaking code path, so this is
  350. // an accepted approximation, not a guarantee of matching the exact wrap points).
  351. // The per-run highlight segmentation below is a single-line layout and does not
  352. // yet extend across wrapped lines — left as a known, documented visual
  353. // approximation gap for this story; a per-run-aware wrapped highlight is polish-
  354. // tier scope for a later story, not required here.
  355. var boxRect = new RectangleF((float)drawX, (float)drawY, (float)(width * transform.Scale), (float)boxHeight);
  356. g.DrawString(element.DisplayText, font, brush, boxRect);
  357. }
  358. else
  359. {
  360. // Sprint 5, "Mix static text and CSV fields within a single text element", AC4: a
  361. // visible placeholder highlight per field-run *segment* rather than one
  362. // whole-element box — literal text within a mixed element gets no fill at all, so
  363. // an operator can see exactly which portion(s) of the line are field tokens versus
  364. // literal text. Sprint 4's unmapped-column warning color is now decided per run
  365. // (see AddressBlockPreviewCalculator's per-run ElementPreviewState.UnmappedRuns)
  366. // rather than for the whole element, so a mixed element with one bad token among
  367. // several good ones only flags that one segment.
  368. var segments = MeasureRunSegments(element, font);
  369. var cumulativeWidth = 0.0;
  370. for (var i = 0; i < segments.Count; i++)
  371. {
  372. var (run, segmentWidth) = segments[i];
  373. if (run.IsField)
  374. {
  375. var isRunUnmapped = i < state.UnmappedRuns.Count && state.UnmappedRuns[i];
  376. var fillColor = isRunUnmapped
  377. ? System.Drawing.Color.FromArgb(70, System.Drawing.Color.OrangeRed)
  378. : System.Drawing.Color.FromArgb(60, System.Drawing.Color.DodgerBlue);
  379. using var dynamicFill = new SolidBrush(fillColor);
  380. var segX = drawX + (cumulativeWidth * transform.Scale);
  381. var segBoxWidth = segmentWidth * transform.Scale;
  382. g.FillRectangle(dynamicFill, (float)segX, (float)drawY, (float)segBoxWidth, (float)boxHeight);
  383. }
  384. cumulativeWidth += segmentWidth;
  385. }
  386. g.DrawString(element.DisplayText, font, brush, (float)drawX, (float)drawY);
  387. }
  388. if (state.IsUnmappedColumn)
  389. {
  390. using var warnPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
  391. g.DrawRectangle(
  392. warnPen, (float)drawX - 1, (float)drawY - 1,
  393. (float)(width * transform.Scale) + 2, (float)(height * transform.Scale) + 2);
  394. }
  395. else if (element.CollapseIfBlank)
  396. {
  397. // A small marker for a configured-collapsible field, regardless of whether it
  398. // happens to be blank right now — lets an operator see at a glance which fields
  399. // are configured to collapse, without needing to select each one individually.
  400. using var badgePen = new Pen(System.Drawing.Color.SeaGreen, 2);
  401. var badgeY = (float)(drawY + (height * transform.Scale) + 2);
  402. var badgeWidth = (float)Math.Min(width * transform.Scale, 16);
  403. g.DrawLine(badgePen, (float)drawX, badgeY, (float)drawX + badgeWidth, badgeY);
  404. }
  405. if (isSelected)
  406. {
  407. var selWidth = width * transform.Scale;
  408. var selHeight = height * transform.Scale;
  409. using var pen = new Pen(System.Drawing.Color.DodgerBlue, 1) { DashStyle = DashStyle.Dash };
  410. g.DrawRectangle(pen, (float)drawX - 2, (float)drawY - 2, (float)selWidth + 4, (float)selHeight + 4);
  411. }
  412. }
  413. finally
  414. {
  415. if (savedState is not null)
  416. {
  417. g.Restore(savedState);
  418. }
  419. }
  420. }
  421. /// <summary>Post-Sprint-8 user-requested feature: draws the font-size resize handle at the
  422. /// selected element's top-right corner (<see cref="CanvasElementEditor.ResizeHandlePosition"/>,
  423. /// which already accounts for rotation) — a small square, the same MediumSeaGreen-fill/white-
  424. /// outline style already used for the Address Control's resize handle, deliberately distinct
  425. /// from the rotate handle's round dot so the two are never confused at a glance.</summary>
  426. private void DrawResizeHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
  427. {
  428. var handle = _editor.ResizeHandlePosition();
  429. if (handle is null)
  430. {
  431. return;
  432. }
  433. var (handleX, handleY) = transform.ToPixels(handle.Value.X, handle.Value.Y);
  434. var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
  435. using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
  436. using var outline = new Pen(System.Drawing.Color.White, 1);
  437. g.FillRectangle(brush, rect);
  438. g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
  439. }
  440. /// <summary>Sprint 4, "Rotate elements by dragging a handle on the canvas": draws a small
  441. /// dot connected to the selected element's bounding-box center by a dotted line, at the
  442. /// world-space position <see cref="CanvasElementEditor.HandlePosition"/> computes (which
  443. /// already accounts for the element's current rotation) — the same position
  444. /// <see cref="CanvasElementEditor.HitTestHandle"/> checks against, so what's drawn is exactly
  445. /// what's draggable.</summary>
  446. private void DrawRotateHandle(Graphics g, CanvasViewTransform transform, TextElementLayout element)
  447. {
  448. var handle = _editor.HandlePosition();
  449. if (handle is null)
  450. {
  451. return;
  452. }
  453. var (width, height) = MeasureElement(element);
  454. var (pivotX, pivotY) = RotationPivot(element, element.Y, width, height);
  455. var (centerPx, centerPy) = transform.ToPixels(pivotX, pivotY);
  456. var (handlePx, handlePy) = transform.ToPixels(handle.Value.X, handle.Value.Y);
  457. using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
  458. g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
  459. const float radius = 5f;
  460. using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
  461. using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
  462. g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  463. g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  464. }
  465. /// <summary>Sprint 8, "Rotate the whole Address Control as a single unit": when
  466. /// <see cref="AddressControlLayout.RotationAngle"/> is non-zero, the entire method body below
  467. /// — the box border, every line's text and per-line highlight/selection overlay, and the
  468. /// resize handle — is drawn under one GDI+ rotation transform around
  469. /// <see cref="AddressControlLayout.BoxCenter"/>, the same way <see cref="DrawElement"/> already
  470. /// rotates a standalone element's whole draw call. Because every pixel-space draw call inside
  471. /// this method (box, lines, handle) shares that one transform, they all visually rotate
  472. /// together as one rigid unit — matching what <see cref="HitTestAddressControl"/> and
  473. /// <see cref="HitTestAddressResizeHandle"/> independently confirm by rotating the click point
  474. /// back into this same unrotated local space before testing.</summary>
  475. private void DrawAddressControl(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
  476. {
  477. GraphicsState? savedState = null;
  478. if (control.RotationAngle != 0)
  479. {
  480. var (pivotX, pivotY) = control.BoxCenter;
  481. var (pivotXPx, pivotYPx) = transform.ToPixels(pivotX, pivotY);
  482. savedState = g.Save();
  483. g.TranslateTransform((float)pivotXPx, (float)pivotYPx);
  484. // See DrawElement's remarks: GDI+'s RotateTransform is visually clockwise-positive in
  485. // this Y-down pixel space, the opposite of this project's counterclockwise-positive
  486. // stored convention, hence the negation.
  487. g.RotateTransform((float)-control.RotationAngle);
  488. g.TranslateTransform((float)-pivotXPx, (float)-pivotYPx);
  489. }
  490. try
  491. {
  492. DrawAddressControlUnrotated(g, transform, control, isSelected);
  493. }
  494. finally
  495. {
  496. if (savedState is not null)
  497. {
  498. g.Restore(savedState);
  499. }
  500. }
  501. }
  502. private void DrawAddressControlUnrotated(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
  503. {
  504. var lineStates = ComputeAddressControlLineStates(control);
  505. using var selectedPen = new Pen(System.Drawing.Color.SeaGreen, 1.5f) { DashStyle = DashStyle.Dash };
  506. using var borderPen = new Pen(System.Drawing.Color.FromArgb(120, System.Drawing.Color.SeaGreen), 1);
  507. var (left, top) = transform.ToPixels(control.X, control.Y + MaxLineFontSize(control));
  508. var (right, bottom) = transform.ToPixels(control.X + control.Width, control.Y - control.Height);
  509. var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
  510. g.DrawRectangle(isSelected ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
  511. if (isSelected)
  512. {
  513. DrawAddressResizeHandle(g, transform, control);
  514. // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
  515. // drawn here, in the control's own local (unrotated) coordinates, so it automatically
  516. // rotates together with the box/lines via DrawAddressControl's enclosing GDI+
  517. // transform — the same reason the resize handle above already orbits correctly.
  518. DrawAddressRotateHandle(g, transform, control);
  519. }
  520. for (var i = 0; i < control.Lines.Count; i++)
  521. {
  522. if (!lineStates[i].Visible)
  523. {
  524. continue;
  525. }
  526. var line = control.Lines[i];
  527. using var font = ResolveFont(line.FontFamily, (float)line.FontSize);
  528. var text = ResolveAddressLinePreviewText(line) ?? line.DisplayText;
  529. var size = MeasureText(text, font);
  530. var (drawX, drawY) = transform.ToPixels(control.X, lineStates[i].EffectiveY + size.Height);
  531. if (line.IsDynamic)
  532. {
  533. using var fill = new SolidBrush(System.Drawing.Color.FromArgb(45, System.Drawing.Color.DodgerBlue));
  534. g.FillRectangle(
  535. fill,
  536. (float)drawX,
  537. (float)drawY,
  538. (float)Math.Min(control.Width * transform.Scale, Math.Max(6, size.Width * transform.Scale)),
  539. (float)(size.Height * transform.Scale));
  540. }
  541. using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B));
  542. g.DrawString(text, font, brush, (float)drawX, (float)drawY);
  543. if (isSelected && i == _selectedAddressLineIndex)
  544. {
  545. using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1);
  546. g.DrawRectangle(
  547. linePen,
  548. (float)drawX - 2,
  549. (float)drawY - 2,
  550. (float)(Math.Max(size.Width, control.Width) * transform.Scale) + 4,
  551. (float)(size.Height * transform.Scale) + 4);
  552. }
  553. }
  554. }
  555. private static double MaxLineFontSize(AddressControlLayout control) =>
  556. control.Lines.Count == 0 ? 0 : control.Lines.Max(l => l.FontSize);
  557. private static void DrawAddressResizeHandle(
  558. Graphics g, CanvasViewTransform transform, AddressControlLayout control)
  559. {
  560. var handle = AddressResizeHandleCenter(control);
  561. var (handleX, handleY) = transform.ToPixels(handle.X, handle.Y);
  562. var rect = new RectangleF((float)handleX - 4, (float)handleY - 4, 8, 8);
  563. using var brush = new SolidBrush(System.Drawing.Color.MediumSeaGreen);
  564. using var outline = new Pen(System.Drawing.Color.White, 1);
  565. g.FillRectangle(brush, rect);
  566. g.DrawRectangle(outline, rect.X, rect.Y, rect.Width, rect.Height);
  567. }
  568. private static (double X, double Y) AddressResizeHandleCenter(AddressControlLayout control)
  569. {
  570. var top = control.Y + MaxLineFontSize(control);
  571. var bottom = control.Y - control.Height;
  572. return (control.X + control.Width, bottom + ((top - bottom) / 2.0));
  573. }
  574. /// <summary>Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas":
  575. /// paints the drag handle for a selected Address Control, in the same visual style
  576. /// (SeaGreen dot, dashed connector line, white outline) as the standalone-element rotate
  577. /// handle (<see cref="DrawRotateHandle"/>) for consistency — the recommended default per this
  578. /// story's own notes, absent a strong reason to diverge. Position math lives in the
  579. /// framework-free, unit-tested <see cref="AddressControlRotateHandle"/> (mirroring how
  580. /// <see cref="CanvasElementEditor"/> holds the standalone-element equivalent); drawn here in
  581. /// local (unrotated) coordinates, which is sufficient to make it visually orbit with the
  582. /// control's own rotation via <see cref="DrawAddressControl"/>'s enclosing GDI+ transform —
  583. /// the same reason the resize handle above already orbits correctly.</summary>
  584. private static void DrawAddressRotateHandle(Graphics g, CanvasViewTransform transform, AddressControlLayout control)
  585. {
  586. var (centerX, topY) = AddressControlRotateHandle.LocalOrigin(control);
  587. var (handleX, handleY) = AddressControlRotateHandle.LocalPosition(control);
  588. var (centerPx, centerPy) = transform.ToPixels(centerX, topY);
  589. var (handlePx, handlePy) = transform.ToPixels(handleX, handleY);
  590. using var linePen = new Pen(System.Drawing.Color.SeaGreen, 1) { DashStyle = DashStyle.Dot };
  591. g.DrawLine(linePen, (float)centerPx, (float)centerPy, (float)handlePx, (float)handlePy);
  592. const float radius = 5f;
  593. using var handleBrush = new SolidBrush(System.Drawing.Color.SeaGreen);
  594. using var handleOutline = new Pen(System.Drawing.Color.White, 1.5f);
  595. g.FillEllipse(handleBrush, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  596. g.DrawEllipse(handleOutline, (float)handlePx - radius, (float)handlePy - radius, radius * 2, radius * 2);
  597. }
  598. private AddressLineCollapser.Resolved[] ComputeAddressControlLineStates(AddressControlLayout control)
  599. {
  600. var lines = new AddressLineCollapser.Line[control.Lines.Count];
  601. for (var i = 0; i < control.Lines.Count; i++)
  602. {
  603. var line = control.Lines[i];
  604. var resolvedText = ResolveAddressLinePreviewText(line);
  605. var shouldCollapse = line.CollapseIfBlank && resolvedText is not null && string.IsNullOrWhiteSpace(resolvedText);
  606. lines[i] = new AddressLineCollapser.Line(control.X, control.BaselineYForLine(i), shouldCollapse);
  607. }
  608. return AddressLineCollapser.Resolve(lines).ToArray();
  609. }
  610. /// <summary>Sprint 7: delegates to the shared <see cref="TextResolver"/> (also used by
  611. /// <see cref="AddressBlockPreviewCalculator"/> and the new preview panel) instead of keeping
  612. /// its own identical copy of this per-run resolution rule.</summary>
  613. private string? ResolveAddressLinePreviewText(AddressControlLineLayout line) =>
  614. TextResolver.TryResolve(line.Runs, _csvHeaders, _csvSampleRecord);
  615. /// <summary>Sprint 6 defect fix (record-accurate render/preview only, not this canvas — see
  616. /// below): static text rotates around its measured center, while dynamic/mixed content rotates
  617. /// around its fixed authored anchor so record-to-record text-width changes cannot move the
  618. /// pivot. Post-Sprint-7-review fix (2026-10-19): this editing canvas only ever draws an
  619. /// element's literal `{ColumnName}` token text, never a per-record resolved value (see
  620. /// <see cref="RotationPivotCalculator.ComputeForCanvasEditing"/>'s remarks), so the dynamic/
  621. /// mixed-content drift the anchor-pivot branch guards against cannot happen here — this method
  622. /// now always takes the bounding-box-center path for every element, static or not, so a
  623. /// dynamic/mixed element's rotate-handle drag spins in place like static text instead of
  624. /// swinging around a corner. The real render (<c>RotatedTextAnchorCalculator</c>/
  625. /// <c>DebenuPdfRenderer</c>) and the new preview panel (<c>TemplatePreviewControl</c>/
  626. /// <c>TemplatePreviewBuilder</c>) are unaffected — they still call
  627. /// <see cref="RotationPivotCalculator.Compute"/> directly with the real <c>isDynamic</c>
  628. /// value.</summary>
  629. private static (double X, double Y) RotationPivot(
  630. TextElementLayout element, double effectiveY, double width, double height) =>
  631. RotationPivotCalculator.ComputeForCanvasEditing(element.X, effectiveY, width, height);
  632. /// <summary>Measures an element's rendered size in canvas-space points, using a
  633. /// <see cref="GraphicsUnit.Point"/> measuring context so the result is directly comparable to
  634. /// the point-based coordinates <see cref="TextElementLayout"/> stores — this is a design-time
  635. /// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point
  636. /// parity with the final PDF. Sprint 9, "Add an adjustable width and height with text
  637. /// wrapping...": once an element has a box (<see cref="TextElementLayout.HasBox"/>), its size
  638. /// *is* the box — authored geometry, not something to (re-)measure from text — so every
  639. /// downstream consumer of this method (hit-testing, the rotate handle, the resize handle, and
  640. /// <see cref="DrawElement"/> itself) automatically treats the box as the element's real
  641. /// bounding box with no changes needed at those call sites.</summary>
  642. private static (double Width, double Height) MeasureElement(TextElementLayout element)
  643. {
  644. if (element.HasBox)
  645. {
  646. return (element.Width!.Value, element.Height!.Value);
  647. }
  648. using var bitmap = new Bitmap(1, 1);
  649. using var g = Graphics.FromImage(bitmap);
  650. g.PageUnit = GraphicsUnit.Point;
  651. using var font = ResolveFont(element.FontFamily, (float)element.FontSize);
  652. var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
  653. var size = g.MeasureString(text, font);
  654. return (size.Width, size.Height);
  655. }
  656. private static (double Width, double Height) MeasureText(string text, Font font)
  657. {
  658. using var bitmap = new Bitmap(1, 1);
  659. using var g = Graphics.FromImage(bitmap);
  660. g.PageUnit = GraphicsUnit.Point;
  661. var size = g.MeasureString(string.IsNullOrEmpty(text) ? " " : text, font);
  662. return (size.Width, size.Height);
  663. }
  664. /// <summary>Sprint 5, AC4: measures each run's own display segment width in canvas-space
  665. /// points (same <see cref="GraphicsUnit.Point"/> measuring context as <see cref="MeasureElement"/>,
  666. /// for consistency), so <see cref="DrawElement"/> can position a per-run highlight fill at the
  667. /// right cumulative offset. Like <see cref="MeasureElement"/>, this is a design-time visual
  668. /// approximation — GDI+'s per-segment measurement summed this way does not necessarily equal
  669. /// its measurement of the whole concatenated string to the last fraction of a point (kerning/
  670. /// spacing metrics are not strictly additive), which is an accepted, documented limitation of
  671. /// a canvas preview rather than a rendering-affecting one (the CLI's real render always draws
  672. /// one already-concatenated string, never per-run pieces).</summary>
  673. private static IReadOnlyList<(TextRun Run, double Width)> MeasureRunSegments(TextElementLayout element, Font font)
  674. {
  675. using var bitmap = new Bitmap(1, 1);
  676. using var g = Graphics.FromImage(bitmap);
  677. g.PageUnit = GraphicsUnit.Point;
  678. var segments = TextRunTextConverter.ToDisplaySegments(element.Runs);
  679. var result = new List<(TextRun, double)>(segments.Count);
  680. foreach (var (run, text) in segments)
  681. {
  682. var width = text.Length == 0 ? 0.0 : g.MeasureString(text, font).Width;
  683. result.Add((run, width));
  684. }
  685. return result;
  686. }
  687. /// <summary>Falls back to a generic sans-serif font if the requested family isn't installed,
  688. /// so a missing font only affects the design-time preview's appearance — it does not crash
  689. /// the designer. This is independent of, and does not relax, the render-time rule that a
  690. /// missing font is a blocking error (`TEMPLATE_FORMAT.md`'s "Known gaps").</summary>
  691. private static Font ResolveFont(string familyName, float size)
  692. {
  693. try
  694. {
  695. return new Font(familyName, size <= 0 ? 12f : size, GraphicsUnit.Point);
  696. }
  697. catch (ArgumentException)
  698. {
  699. return new Font(FontFamily.GenericSansSerif, size <= 0 ? 12f : size, GraphicsUnit.Point);
  700. }
  701. }
  702. protected override void OnMouseDown(MouseEventArgs e)
  703. {
  704. base.OnMouseDown(e);
  705. if (e.Button != MouseButtons.Left)
  706. {
  707. return;
  708. }
  709. var transform = CurrentTransform();
  710. var (x, y) = transform.ToPoints(e.X, e.Y);
  711. if (_selectedAddressControl is not null && HitTestAddressResizeHandle(_selectedAddressControl, x, y, transform))
  712. {
  713. _isResizingAddressControl = true;
  714. Capture = true;
  715. Invalidate();
  716. return;
  717. }
  718. // Sprint 8, "Rotate the whole Address Control by dragging a handle on the canvas": checked
  719. // right alongside the resize-handle check above (same precedence rule: only meaningful,
  720. // and only checked, when this control is already selected) so a rotate-handle grab is
  721. // never confused with a normal select/move click elsewhere on the control.
  722. if (_selectedAddressControl is not null && AddressControlRotateHandle.HitTest(_selectedAddressControl, x, y))
  723. {
  724. _isRotatingAddressControl = true;
  725. Capture = true;
  726. Invalidate();
  727. return;
  728. }
  729. var addressHit = HitTestAddressControl(x, y);
  730. if (addressHit.Control is not null)
  731. {
  732. SelectAddressControl(addressHit.Control, addressHit.LineIndex);
  733. _addressControlDragOffset = (x - addressHit.Control.X, y - addressHit.Control.Y);
  734. Capture = true;
  735. Invalidate();
  736. SelectionChanged?.Invoke(this, EventArgs.Empty);
  737. return;
  738. }
  739. ClearAddressSelection();
  740. // Post-Sprint-8 user-requested feature: a click on the selected element's font-size
  741. // resize handle starts a resize-drag, checked with the same precedence as the rotate
  742. // handle immediately below (only meaningful, and only checked, once something is already
  743. // selected) so it is never confused with a normal select/move click.
  744. if (_editor.Selected is not null && _editor.HitTestResizeHandle(x, y))
  745. {
  746. _editor.BeginResizeDrag();
  747. Capture = true;
  748. Invalidate();
  749. return;
  750. }
  751. // Sprint 4: a click on the currently selected element's rotate handle starts a rotate-drag
  752. // instead of a normal select/move — checked first (and only when something is already
  753. // selected) so it never intercepts a normal click elsewhere on the canvas.
  754. if (_editor.Selected is not null && _editor.HitTestHandle(x, y))
  755. {
  756. _editor.BeginRotateDrag();
  757. Capture = true;
  758. Invalidate();
  759. return;
  760. }
  761. var hit = _editor.TrySelectAt(x, y);
  762. if (hit)
  763. {
  764. _editor.BeginDrag(x, y);
  765. Capture = true;
  766. }
  767. Invalidate();
  768. SelectionChanged?.Invoke(this, EventArgs.Empty);
  769. }
  770. /// <summary>Sprint 8: a rotated control must remain correctly click-selectable at its actual
  771. /// rotated position, not its unrotated bounding box — ported from
  772. /// <c>CanvasElementEditor.IsPointInRotatedBounds</c>'s approach: rotate the click point
  773. /// backward (by <c>-RotationAngle</c>) around the same <see cref="AddressControlLayout.BoxCenter"/>
  774. /// pivot <see cref="DrawAddressControl"/> rotates around, landing it back in the control's
  775. /// unrotated local space, then run the exact same plain-rectangle/line test as before. An
  776. /// unrotated control (the overwhelmingly common case) takes the cheap direct path.</summary>
  777. private (AddressControlLayout? Control, int LineIndex) HitTestAddressControl(double xPoints, double yPoints)
  778. {
  779. foreach (var control in _document.AddressControls.OrderByDescending(c => c.ZOrder))
  780. {
  781. var (testX, testY) = control.RotationAngle == 0
  782. ? (xPoints, yPoints)
  783. : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
  784. var top = control.Y + MaxLineFontSize(control);
  785. var bottom = control.Y - control.Height;
  786. if (testX < control.X || testX > control.X + control.Width || testY < bottom || testY > top)
  787. {
  788. continue;
  789. }
  790. var lineIndex = 0;
  791. for (var i = 0; i < control.Lines.Count; i++)
  792. {
  793. var baseline = control.BaselineYForLine(i);
  794. var lineTop = baseline + control.Lines[i].FontSize;
  795. var nextBaseline = i == control.Lines.Count - 1
  796. ? bottom
  797. : control.BaselineYForLine(i + 1);
  798. if (testY <= lineTop && testY >= nextBaseline)
  799. {
  800. lineIndex = i;
  801. break;
  802. }
  803. }
  804. return (control, lineIndex);
  805. }
  806. return (null, 0);
  807. }
  808. /// <summary>Sprint 8: same rotate-the-click-point-backward approach as
  809. /// <see cref="HitTestAddressControl"/> — the resize handle is drawn (via
  810. /// <see cref="DrawAddressResizeHandle"/>) inside <see cref="DrawAddressControl"/>'s rotation
  811. /// transform, so it visually orbits with the rotated box; the hit test rotates the click point
  812. /// back into the same unrotated local space before comparing against the handle's unrotated
  813. /// position.</summary>
  814. private static bool HitTestAddressResizeHandle(
  815. AddressControlLayout control, double xPoints, double yPoints, CanvasViewTransform transform)
  816. {
  817. const double handleTolerancePixels = 8;
  818. var tolerancePoints = handleTolerancePixels / transform.Scale;
  819. var (testX, testY) = control.RotationAngle == 0
  820. ? (xPoints, yPoints)
  821. : PointRotation.RotateAroundPivot(xPoints, yPoints, control.BoxCenter, -control.RotationAngle);
  822. var handle = AddressResizeHandleCenter(control);
  823. return Math.Abs(testX - handle.X) <= tolerancePoints
  824. && Math.Abs(testY - handle.Y) <= tolerancePoints;
  825. }
  826. protected override void OnMouseMove(MouseEventArgs e)
  827. {
  828. base.OnMouseMove(e);
  829. if (!IsInteracting)
  830. {
  831. return;
  832. }
  833. var (x, y) = CurrentTransform().ToPoints(e.X, e.Y);
  834. if (_selectedAddressControl is not null && _isRotatingAddressControl)
  835. {
  836. AddressControlRotateHandle.RotateDragTo(_selectedAddressControl, x, y);
  837. Invalidate();
  838. ElementsChanged?.Invoke(this, EventArgs.Empty);
  839. return;
  840. }
  841. if (_selectedAddressControl is not null && _isResizingAddressControl)
  842. {
  843. // Sprint 8: rotate the drag point backward into the control's unrotated local space
  844. // first (same as the hit test above) so resizing a rotated control still tracks the
  845. // mouse along the box's own local width axis, not the world X axis.
  846. var (localX, _) = _selectedAddressControl.RotationAngle == 0
  847. ? (x, y)
  848. : PointRotation.RotateAroundPivot(x, y, _selectedAddressControl.BoxCenter, -_selectedAddressControl.RotationAngle);
  849. var width = Math.Max(1, localX - _selectedAddressControl.X);
  850. _selectedAddressControl.Width = SnapToGridEnabled ? Math.Max(1, GridSnapper.Snap(width, GridSizePoints)) : width;
  851. Invalidate();
  852. ElementsChanged?.Invoke(this, EventArgs.Empty);
  853. return;
  854. }
  855. if (_selectedAddressControl is not null && _addressControlDragOffset is not null)
  856. {
  857. var newX = x - _addressControlDragOffset.Value.Dx;
  858. var newY = y - _addressControlDragOffset.Value.Dy;
  859. if (SnapToGridEnabled)
  860. {
  861. newX = GridSnapper.Snap(newX, GridSizePoints);
  862. newY = GridSnapper.Snap(newY, GridSizePoints);
  863. }
  864. _selectedAddressControl.X = newX;
  865. _selectedAddressControl.Y = newY;
  866. Invalidate();
  867. ElementsChanged?.Invoke(this, EventArgs.Empty);
  868. return;
  869. }
  870. if (_editor.IsResizingFontSize)
  871. {
  872. _editor.ResizeDragTo(x, y);
  873. }
  874. else if (_editor.IsRotating)
  875. {
  876. _editor.RotateDragTo(x, y);
  877. }
  878. else
  879. {
  880. _editor.DragTo(x, y);
  881. }
  882. Invalidate();
  883. ElementsChanged?.Invoke(this, EventArgs.Empty);
  884. }
  885. protected override void OnMouseUp(MouseEventArgs e)
  886. {
  887. base.OnMouseUp(e);
  888. var wasInteracting = IsInteracting;
  889. _editor.EndDrag();
  890. _addressControlDragOffset = null;
  891. _isResizingAddressControl = false;
  892. _isRotatingAddressControl = false;
  893. Capture = false;
  894. // The gesture's own per-tick ElementsChanged notifications (OnMouseMove) only ask
  895. // TemplateDesignerForm for a cheap position/angle-only sync (see IsInteracting's
  896. // remarks) — fire one last notification now that the gesture has actually ended so the
  897. // form's full properties-panel refresh (rebind combo, address line list, etc.) still
  898. // runs exactly once at the end, the same as it always did before this smoothness fix.
  899. if (wasInteracting)
  900. {
  901. ElementsChanged?.Invoke(this, EventArgs.Empty);
  902. }
  903. }
  904. }

Powered by TurnKey Linux.