瀏覽代碼

Sprint 10 Batch 2: multi-select on the canvas (rubber-band, modifier-click, group drag)

Adds an additive multi-selection layer alongside the existing single-select
state for both standalone elements and Address Controls: rubber-band drag,
Ctrl/Shift-click toggle, group drag preserving relative offsets, per-item
highlight, and a disabled "N items selected" properties panel. Collapses
back to ordinary single-select whenever 0-1 items remain selected so every
existing code path is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 4 天之前
父節點
當前提交
057e0182a5
共有 9 個文件被更改,包括 847 次插入21 次删除
  1. +1
    -1
      backlog/backlog.md
  2. +62
    -6
      backlog/epics/06_layout_efficiency_and_operator_tooling.md
  3. +2
    -1
      backlog/sprints/sprint-10.md
  4. +150
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
  5. +189
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
  6. +411
    -11
      code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
  7. +26
    -0
      code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
  8. +2
    -1
      logs/technical_debt_log.md
  9. +4
    -1
      state.md

+ 1
- 1
backlog/backlog.md 查看文件

@@ -10,7 +10,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
| 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) |
| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Add a live wrap/clip indicator for text elements" (re-scoped, 3 pts) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) |
| 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Done (4 of 4 stories — Sprint 5, 6, 8; whole-control rotation delivered Sprint 8, see Sprint 8 Review outcome below) |
| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (1 of 4 stories Done — Sprint 7; "Select multiple elements at once on the canvas" and "Align and distribute multiple elements" Ready as a dependent pair, "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) |
| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (2 of 4 stories Done — "Snap elements to grid and guides" Sprint 7, "Select multiple elements at once on the canvas" Sprint 10 Batch 2; "Align and distribute multiple elements" Ready, committed as Sprint 10 Batch 3, depends on the multi-selection now shipped; "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) |
| 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) |

## Notes


+ 62
- 6
backlog/epics/06_layout_efficiency_and_operator_tooling.md 查看文件

@@ -88,7 +88,63 @@ professional layouts quickly.
editor/canvas pair; no cross-layer (CLI/XML) work needed.
**Dependencies:** None.

### Select multiple elements at once on the canvas - Status: Ready
### Select multiple elements at once on the canvas - Status: Done
**Development verification note (Sprint 10 Batch 2, 2026-10-27):** Implemented as a strictly
additive layer, exactly per this story's own conversation notes: `CanvasElementEditor` gained a
`MultiSelected` set (standalone elements) alongside its existing `Selected` field, and
`TemplateCanvasControl` gained a parallel `_multiSelectedAddressControls` set — every existing
single-select code path (properties panel, rotate/resize handles, address-line drill-in) was
verified unchanged when 0 or 1 items end up selected, via a `CollapseMultiSelectionIfSingular` rule
applied after every multi-selection mutation.

- **Rubber-band selection:** a click-drag starting on empty canvas space (checked only after every
handle/element/Address-Control hit-test already misses) draws a marquee and, on release, selects
every standalone element and Address Control whose world-space (rotation-aware) axis-aligned
bounding box intersects it — `CanvasElementEditor.ElementsInRect` (framework-free, unit tested,
including a rotated-element case proving the rotated corners, not the unrotated box, drive the
test) and a parallel `TemplateCanvasControl.AddressControlsInRect` (WinForms-only, same
intentional test-coverage split this epic's own 2026-10-26 technical debt entry already
documents).
- **Modifier-click (Ctrl/Shift):** toggles one item's membership without disturbing the rest,
seeding the multi-selection from whatever was singly selected first if nothing was multi-selected
yet (the standard "extend the current selection" UX) — `CanvasElementEditor.ToggleMultiSelect`
(unit tested) plus `TemplateCanvasControl`'s parallel Address Control toggle.
- **Visible indication for every selected item:** `DrawElement`'s existing highlight condition was
extended to include multi-selected membership; `DrawAddressControl`/`DrawAddressControlUnrotated`
were split into separate `isPrimarySelected` (drives resize/rotate handles) and `isMultiSelected`
(drives only the highlight border) parameters, so a multi-selected Address Control is visibly
marked without showing handles that would be ambiguous about which item they'd act on.
- **Group drag preserving relative offsets:** `CanvasElementEditor.BeginMultiDrag`/`MultiDragTo`
(unit tested, including a snap-to-grid case proving the *delta* is snapped once rather than each
item's absolute position independently — the latter would have pulled unevenly-grid-aligned items
closer together or further apart) plus a parallel `TemplateCanvasControl.ApplyMultiDragToAddressControls`
applying the identical shared delta to the Address Control half every tick.
- **Properties panel:** `TemplateDesignerForm.RefreshPropertiesPanel`/`RefreshSelectionLabel` check
a new `TemplateCanvasControl.IsMultiSelectionActive` first, disabling the whole panel and showing
"N items selected" instead of one arbitrary member's values; the Delete button/key are disabled
during multi-select rather than extended to a bulk operation (out of this story's scope).

Tests: 12 new Desktop.Core tests (`ElementsInRect` including the rotated case, `ToggleMultiSelect`,
`SetMultiSelection`, `ClearMultiSelect`, `MultiDragTo` including the snap-delta case, `EndMultiDrag`,
`BeginMultiDrag`'s no-op-when-empty case). Full suite 364/364 -> 376/376 (Desktop.Core; CLI
unaffected at 124/124). Live-verified with a reflection-driven harness against the real built form:
rubber-band correctly selected 2 of 3 placed elements and excluded the third; the properties panel
showed `Enabled=False`/"2 items selected."; toggling a third element in and a first element back out
produced the exact expected membership; a group drag moved two multi-selected elements by an
identical measured delta while their relative offset stayed exactly unchanged and an unselected
third element stayed untouched; clicking empty canvas space cleared the selection back to zero; a
real canvas screenshot confirmed every multi-selected element shows the highlight border. One
disclosed evidence-depth caveat: `Control.ModifierKeys` (the real mechanism `OnMouseDown` reads to
detect a held Ctrl/Shift) has no public setter and queries live physical keyboard state, so it could
not be driven through a real simulated key-press in this non-interactive harness — the toggle path
was instead verified by calling the same private `ToggleElementMultiSelect` method the modifier-click
branch delegates to, directly, proving that logic correct while disclosing (not overclaiming) that
the `ModifierKeys` read itself wasn't exercised end-to-end. One pre-existing, unrelated cosmetic gap
was found (not fixed) during this verification and logged separately: a plain element's selection
highlight border visibly wraps only part of a multi-word `DisplayText` — present identically for
ordinary single-selection, confirmed unrelated to this story's own changes (see
`logs/technical_debt_log.md`, 2026-10-27).

**Card**
As a **print operator**, I want to select more than one layout element at a time, so that I can
apply the same operation (like alignment) to several elements at once instead of one at a time.
@@ -107,13 +163,13 @@ apply the same operation (like alignment) to several elements at once instead of
selected item) rather than a fixed requirement here.

**Confirmation (Acceptance Criteria)**
- [ ] An operator can select multiple elements via rubber-band drag on empty canvas space.
- [ ] An operator can add or remove a single element from the current selection via a modifier
- [x] An operator can select multiple elements via rubber-band drag on empty canvas space.
- [x] An operator can add or remove a single element from the current selection via a modifier
click.
- [ ] All selected elements are visibly indicated as selected on the canvas.
- [ ] Dragging any selected element moves the entire selection together, preserving each
- [x] All selected elements are visibly indicated as selected on the canvas.
- [x] Dragging any selected element moves the entire selection together, preserving each
element's relative offset.
- [ ] Verified with an actual built-form smoke (this touches the properties panel's behavior for
- [x] Verified with an actual built-form smoke (this touches the properties panel's behavior for
a multi-item selection, per the Sprint 6 retrospective action).

**Estimate:** 5 points (dev-team, 2026-10-16, via inspection of `CanvasElementEditor` and


+ 2
- 1
backlog/sprints/sprint-10.md 查看文件

@@ -8,7 +8,7 @@
| Story | Size | Status | Tasks |
|---|---|---|---|
| Add a live wrap/clip indicator for text elements | 3 points | Done | - [x] Determine, per selected/previewed element with a box, whether the resolved text's natural (unwrapped) GDI+-measured size exceeds the box width (wrapping) and/or the wrapped height exceeds the box height (clipping) <br> - [x] Draw a small visual indicator (dashed-orange-style, consistent with the existing unmapped-column warning) on `TemplateCanvasControl` and `TemplatePreviewControl` when either condition is true for the current sample record, drawn inside the existing per-element rotation transform so it rotates for free <br> - [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free) <br> - [x] Live built-form smoke: a narrow (120pt) boxed element's real canvas screenshot shows both genuine multi-line wrap and the dashed-orange indicator appearing correctly |
| Select multiple elements at once on the canvas | 5 points | Not Started | - [ ] Add a multi-select data structure alongside the existing single-selection state (`CanvasElementEditor` for standalone elements, `TemplateCanvasControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected <br> - [ ] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release <br> - [ ] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [ ] Visible selection indication for every selected item, not just the primary one <br> - [ ] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [ ] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes) <br> - [ ] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [ ] Live built-form smoke (touches the properties panel's multi-item behavior) |
| Select multiple elements at once on the canvas | 5 points | Done | - [x] Add a multi-select data structure alongside the existing single-selection state (`CanvasElementEditor` for standalone elements, `TemplateCanvasControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected <br> - [x] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release <br> - [x] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest <br> - [x] Visible selection indication for every selected item, not just the primary one <br> - [x] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning) <br> - [x] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes) <br> - [x] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core) <br> - [x] Live built-form smoke (touches the properties panel's multi-item behavior) |
| Align and distribute multiple elements | 5 points | Not Started | - [ ] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height) <br> - [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection <br> - [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise <br> - [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free) <br> - [ ] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) |

## Notes
@@ -35,3 +35,4 @@ Sequenced by dependency and risk: the small, independent indicator story first (
|---|---|---|---|---|
| 1 | 2026-10-27 | **Batch 1** ("Add a live wrap/clip indicator for text elements", 3 points) done, all ACs met. New framework-free `WrapClipDetector` (Desktop.Core) compares an element's natural (unwrapped) GDI+-measured width against its box width, and its GDI+-wrapped height against an explicit clip ceiling when one is set (never flags clipping otherwise, since there is no ceiling to exceed). Wired into `TemplateCanvasControl.DrawElement` and `TemplatePreviewControl.DrawItem`'s existing box-mode branches, drawing a dashed-orange outline (the unmapped-column warning's visual language) inside the same rotation transform every other per-element visual already uses, so it orbits for free when rotated. **Mid-batch, the human product owner raised two live-build issues, addressed together before continuing to Batch 2** (full detail in `epics/02_template_designer_gui_foundation.md`'s and `epics/04_live_preview_and_record_navigation.md`'s post-Sprint-9 correction notes): (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to the Delete/Backspace key and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely, `ResizeDragTo` now only ever sets `Width`, and `TextElementLayout.HasBox`/`TemplateElement.HasBox` were decoupled from `Height` (a new independent `HasHeightClip` flag) as a direct consequence, with `DebenuPdfRenderer.AddPage` gaining a "Width alone" native-wrap-no-clip render path (`DrawWrappedText` unrotated, `GetWrappedText`+`DrawRotatedMultiLineText` rotated). Tests: net +12 (8 obsolete font-scale tests removed, 20 new/updated across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, `TemplateXmlParserTests`, `DebenuPdfRendererWrapTests`, plus the new `WrapClipDetectorTests`). Full suite 476/476 -> 488/488. Live-verified: a real, licensed end-to-end CLI render of a width-only (no height) template showed correct unrotated and rotated auto-height wrap with no clipping; a reflection-driven built-form harness confirmed a real mouse-driven resize drag sets only `Width` (Height/FontSize unchanged), the real Delete key removes the selected element, and a real canvas screenshot shows both genuine wrap and the new indicator on a narrow boxed element. One disclosed evidence-depth caveat: the toolbar Delete button's own `PerformClick()` didn't reliably fire in the synthetic harness (a known WinForms quirk absent a real shown window) — the underlying method was proven correct via a direct call, and the button's wiring matches every other proven-working toolbar button. | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. |
| 2 | 2026-10-27 | **Ad-hoc regression fix, before starting Batch 2:** user reported clicking on and moving a dynamic placeholder had become "not smooth any more." Full detail in `epics/02_template_designer_gui_foundation.md`'s new post-Sprint-10-Batch-1 note. Root cause, found by direct `Stopwatch` instrumentation of `TemplateCanvasControl.OnMouseDown` rather than guessing: `SelectionChanged` fired unconditionally on every left-click, including a click on an already-selected element (the normal way to start a drag), and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time that fires — a cost that only became perceptible (~40-60ms/click, measured) once this sprint's added property-panel rows made the refresh heavier. Fixed by only raising `SelectionChanged` when the pre-click and post-click selection identity actually differ. Live-verified with the same instrumented harness: an already-selected element's second click dropped from ~50ms to 0.295ms; a genuine selection change still pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix is isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. |
| 3 | 2026-10-27 | **Batch 2** ("Select multiple elements at once on the canvas", 5 points) done, all ACs met. Built as a strictly additive layer per the story's own conversation notes: `CanvasElementEditor.MultiSelected` (a `HashSet<TextElementLayout>`) alongside the existing `Selected` field, and a parallel `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls, with a `CollapseMultiSelectionIfSingular` rule ensuring every existing single-select code path keeps working unchanged whenever 0 or 1 items end up selected. Rubber-band selection (`CanvasElementEditor.ElementsInRect`, rotation-aware via rotated-corner AABB, plus a parallel `TemplateCanvasControl.AddressControlsInRect`) and modifier-click toggle (`ToggleMultiSelect`, seeding from the current single selection) both replace or extend the multi-selection on release/click; group drag (`BeginMultiDrag`/`MultiDragTo`, snapping the shared *delta* once rather than each item's position independently) moves the whole set together preserving relative offsets; `DrawAddressControl` was split into separate `isPrimarySelected` (handles) and `isMultiSelected` (highlight-only) parameters so every selected item is visibly marked without ambiguous multi-item handles; the properties panel shows "N items selected" and disables per-item editing (Delete disabled too, rather than extended to a bulk operation — out of this story's scope). Tests: +12 (Desktop.Core, including a rotated-element `ElementsInRect` case and a snap-to-grid `MultiDragTo` case). Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected at 124/124). Live-verified with a reflection-driven harness against the real built form: rubber-band selected exactly the 2 of 3 elements it should; the panel showed the disabled "2 items selected." state; toggling a third item in and the first back out produced the exact expected membership; a group drag moved two elements by an identical measured delta with their relative offset exactly unchanged and the unselected third element untouched; clicking empty space cleared the selection; a real canvas screenshot confirmed every multi-selected item shows the highlight border. One disclosed evidence-depth caveat: `Control.ModifierKeys` has no public setter and queries live keyboard state, so modifier-click was verified by calling the same private toggle method the click handler delegates to, directly, rather than through a real simulated key-press. One pre-existing, unrelated cosmetic gap found (not fixed) during verification: a plain element's selection highlight border visibly wraps only part of a multi-word display string — confirmed present identically for ordinary single-selection, logged to `logs/technical_debt_log.md` (2026-10-27) rather than fixed in-scope. | Batch 3, "Align and distribute multiple elements" (5 points, epic 6, depends on Batch 2's multi-selection now existing). | None. |

+ 150
- 0
code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs 查看文件

@@ -37,6 +37,17 @@ public sealed class CanvasElementEditor
private bool _isRotating;
private bool _isResizingWidth;

/// <summary>Sprint 10, "Select multiple elements at once on the canvas": an *additive* layer
/// alongside <see cref="Selected"/>, not a replacement — every existing single-select code
/// path (properties panel, rotate/resize handles, line drill-in) keeps reading
/// <see cref="Selected"/> unchanged. <see cref="TemplateCanvasControl"/> is responsible for the
/// "collapse to single-select when exactly one item ends up selected, clear <see cref="Selected"/>
/// when two or more are" rule, since it is the only place that knows about both this set and the
/// parallel Address Control multi-selection set.</summary>
private readonly HashSet<TextElementLayout> _multiSelected = new();
private (double X, double Y)? _multiDragGrabPoint;
private Dictionary<TextElementLayout, (double X, double Y)>? _multiDragOriginalPositions;

public CanvasElementEditor(
TemplateLayoutDocument document, Func<TextElementLayout, (double Width, double Height)> measureText)
{
@@ -148,6 +159,145 @@ public sealed class CanvasElementEditor

public void Select(TextElementLayout? element) => Selected = element;

/// <summary>Sprint 10: the standalone elements currently part of the multi-selection — see
/// this field's own remarks for the collapse rule <see cref="TemplateCanvasControl"/>
/// applies.</summary>
public IReadOnlySet<TextElementLayout> MultiSelected => _multiSelected;

/// <summary>Replaces the entire multi-selection set (used by rubber-band selection, which
/// determines the whole new set at once from a rectangle rather than toggling one item at a
/// time).</summary>
public void SetMultiSelection(IEnumerable<TextElementLayout> elements)
{
_multiSelected.Clear();
foreach (var element in elements)
{
_multiSelected.Add(element);
}
}

/// <summary>Modifier-click support: adds the element if it isn't already part of the
/// multi-selection, removes it if it is — every other member is left untouched.</summary>
public void ToggleMultiSelect(TextElementLayout element)
{
if (!_multiSelected.Remove(element))
{
_multiSelected.Add(element);
}
}

public void ClearMultiSelect() => _multiSelected.Clear();

/// <summary>Rubber-band selection support: every standalone element whose world-space
/// (rotation-aware) axis-aligned bounding box intersects the given rectangle. A rotated
/// element's own AABB (the bounding box of its four rotated corners, not its unrotated box) is
/// used — a documented simplification consistent with this canvas's other rotation-aware but
/// approximate hit-testing (see <see cref="IsPointInRotatedBounds"/>'s sibling remarks):
/// correct for axis-aligned selection rectangles, though a very obliquely rotated element's
/// true (rotated) outline could extend slightly beyond what a tight rectangle actually
/// touches.</summary>
public IReadOnlyList<TextElementLayout> ElementsInRect(double minX, double minY, double maxX, double maxY)
{
var result = new List<TextElementLayout>();
foreach (var element in _document.Elements)
{
var (width, height) = _measureText(element);
var (elMinX, elMinY, elMaxX, elMaxY) = WorldBounds(element, width, height);
if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
{
result.Add(element);
}
}

return result;
}

private static (double MinX, double MinY, double MaxX, double MaxY) WorldBounds(
TextElementLayout element, double width, double height)
{
if (element.RotationAngle == 0)
{
return (element.X, element.Y, element.X + width, element.Y + height);
}

var pivot = RotationPivot(element, width, height);
var corners = new[]
{
(element.X, element.Y),
(element.X + width, element.Y),
(element.X, element.Y + height),
(element.X + width, element.Y + height),
};

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) = RotatePointAroundPivot(cornerX, cornerY, pivot, element.RotationAngle);
minX = Math.Min(minX, rx);
minY = Math.Min(minY, ry);
maxX = Math.Max(maxX, rx);
maxY = Math.Max(maxY, ry);
}

return (minX, minY, maxX, maxY);
}

/// <summary>Starts a group drag of every element currently in <see cref="MultiSelected"/>,
/// capturing each one's original position so <see cref="MultiDragTo"/> can move the whole set
/// by one shared delta — preserving every item's relative offset, matching how
/// <see cref="BeginDrag"/>/<see cref="DragTo"/> preserve a single element's grab offset.
/// No-op if nothing is multi-selected.</summary>
public void BeginMultiDrag(double grabXPoints, double grabYPoints)
{
if (_multiSelected.Count == 0)
{
return;
}

_multiDragGrabPoint = (grabXPoints, grabYPoints);
_multiDragOriginalPositions = _multiSelected.ToDictionary(e => e, e => (e.X, e.Y));
}

/// <summary>Moves every multi-selected element by the same delta (current pointer position
/// minus the grab point) applied to its own captured original position — delta-based, not
/// absolute repositioning, so relative offsets between items never drift. When
/// <see cref="SnapToGridEnabled"/>, the *delta itself* is snapped once (not each item's final
/// position independently), which is what actually preserves relative offsets: independently
/// snapping each item's absolute position would pull unevenly-spaced items closer together or
/// further apart depending on where each one started relative to the grid.</summary>
public void MultiDragTo(double xPoints, double yPoints)
{
if (_multiDragGrabPoint is null || _multiDragOriginalPositions 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 (element, original) in _multiDragOriginalPositions)
{
element.X = original.X + dx;
element.Y = original.Y + dy;
}
}

public void EndMultiDrag()
{
_multiDragGrabPoint = null;
_multiDragOriginalPositions = null;
}

public bool IsMultiDragging => _multiDragGrabPoint is not null;

/// <summary>Post-Sprint-9 user-reported gap: there was no way to delete a placed standalone
/// element at all. Removes the currently selected element from the document and clears the
/// selection; a no-op if nothing is selected.</summary>


+ 189
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs 查看文件

@@ -755,4 +755,193 @@ public class CanvasElementEditorTests
Assert.Equal(50.0, document.Elements[0].X, precision: 6);
Assert.Equal(40.0, document.Elements[0].Y, precision: 6);
}

// Sprint 10, "Select multiple elements at once on the canvas": an additive layer alongside
// Selected (see CanvasElementEditor.MultiSelected's remarks) — these tests cover the
// framework-free half (rubber-band hit-testing via ElementsInRect, modifier-click toggle via
// ToggleMultiSelect/SetMultiSelection, and multi-drag delta math). The mixed
// standalone-element-plus-Address-Control orchestration (collapse-to-single-select rule,
// rubber-band rectangle finalization, group-drag coordination across both kinds) lives in the
// WinForms-only TemplateCanvasControl and is covered by live/reflection-harness verification
// instead, per this project's established split of testing responsibility for that layer.

[Fact]
public void ElementsInRect_UnrotatedElement_SelectsOnlyElementsWhoseBoundingBoxIntersects()
{
var editor = CreateEditor(out _);
var inside = editor.AddStaticText(10, 10, "Inside"); // box: (10,10)-(30,20) with FixedSize
var outside = editor.AddStaticText(200, 200, "Outside"); // box: (200,200)-(220,210)

var hits = editor.ElementsInRect(0, 0, 50, 50);

Assert.Contains(inside, hits);
Assert.DoesNotContain(outside, hits);
}

[Fact]
public void ElementsInRect_RectanglePartiallyOverlappingElement_StillCounts()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(10, 10, "Hello"); // box: (10,10)-(30,20)

// Rectangle only clips the element's bottom-left corner — a partial overlap, not a
// containment — matching typical rubber-band UX ("touches" selects, not "fully encloses").
var hits = editor.ElementsInRect(0, 0, 15, 15);

Assert.Contains(element, hits);
}

[Fact]
public void ElementsInRect_RotatedElement_UsesRotatedCornersNotUnrotatedBox()
{
var editor = CreateEditor(out _);
// A 20x10 box at (0,0) rotated 90 degrees around its own bounding-box center (10,5) swings
// its corners out to roughly (5,-5)-(15,15) in world space — a point in that swung-out
// region that is NOT inside the original unrotated (0,0)-(20,10) box proves the rotated
// corners (not the unrotated box) drive the intersection test.
var element = editor.AddStaticText(0, 0, "Hello");
element.RotationAngle = 90;

var hitsSwungRegion = editor.ElementsInRect(4, -6, 6, -4); // just outside the unrotated box's Y range
var missesFarAway = editor.ElementsInRect(100, 100, 120, 110);

Assert.Contains(element, hitsSwungRegion);
Assert.Empty(missesFarAway);
}

[Fact]
public void ToggleMultiSelect_ElementNotSelected_AddsIt()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(0, 0, "Hello");

editor.ToggleMultiSelect(element);

Assert.Contains(element, editor.MultiSelected);
}

[Fact]
public void ToggleMultiSelect_ElementAlreadySelected_RemovesItWithoutDisturbingOthers()
{
var editor = CreateEditor(out _);
var a = editor.AddStaticText(0, 0, "A");
var b = editor.AddStaticText(100, 100, "B");
editor.SetMultiSelection(new[] { a, b });

editor.ToggleMultiSelect(a);

Assert.DoesNotContain(a, editor.MultiSelected);
Assert.Contains(b, editor.MultiSelected);
}

[Fact]
public void SetMultiSelection_ReplacesWhateverWasThereBefore()
{
var editor = CreateEditor(out _);
var a = editor.AddStaticText(0, 0, "A");
var b = editor.AddStaticText(100, 100, "B");
editor.SetMultiSelection(new[] { a });

editor.SetMultiSelection(new[] { b });

Assert.DoesNotContain(a, editor.MultiSelected);
Assert.Contains(b, editor.MultiSelected);
}

[Fact]
public void ClearMultiSelect_EmptiesTheSet()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(0, 0, "Hello");
editor.ToggleMultiSelect(element);

editor.ClearMultiSelect();

Assert.Empty(editor.MultiSelected);
}

[Fact]
public void MultiDragTo_MovesEveryMultiSelectedElementByTheSameDelta_PreservingRelativeOffsets()
{
var editor = CreateEditor(out _);
var a = editor.AddStaticText(0, 0, "A");
var b = editor.AddStaticText(100, 50, "B");
editor.SetMultiSelection(new[] { a, b });
editor.BeginMultiDrag(0, 0);

editor.MultiDragTo(10, 5);

Assert.Equal(10.0, a.X, precision: 6);
Assert.Equal(5.0, a.Y, precision: 6);
Assert.Equal(110.0, b.X, precision: 6); // moved by the same (10, 5) delta as A
Assert.Equal(55.0, b.Y, precision: 6);
}

[Fact]
public void MultiDragTo_ContinuesFromTheOriginalCapturedPositions_NotCumulatively()
{
// Each call recomputes from the captured original position and the CURRENT pointer, the
// same "recompute from scratch every tick" style DragTo already uses — calling MultiDragTo
// twice with different endpoints must not compound like two successive relative moves.
var editor = CreateEditor(out _);
var element = editor.AddStaticText(0, 0, "Hello");
editor.SetMultiSelection(new[] { element });
editor.BeginMultiDrag(0, 0);

editor.MultiDragTo(10, 10);
editor.MultiDragTo(30, 5);

Assert.Equal(30.0, element.X, precision: 6);
Assert.Equal(5.0, element.Y, precision: 6);
}

[Fact]
public void MultiDragTo_SnapEnabled_SnapsTheDeltaItself_NotEachItemsFinalPositionIndependently()
{
// Snapping each item's absolute position independently would pull unevenly-grid-aligned
// items closer together or further apart; snapping the shared delta once preserves the
// exact original offset between A and B regardless of where either started relative to
// the grid.
var editor = CreateEditor(out _);
var a = editor.AddStaticText(0, 0, "A");
var b = editor.AddStaticText(3, 7, "B"); // deliberately off-grid relative to A
editor.SnapToGridEnabled = true;
editor.GridSizePoints = 10;
editor.SetMultiSelection(new[] { a, b });
editor.BeginMultiDrag(0, 0);

editor.MultiDragTo(24, 1); // raw delta (24, 1) snaps to (20, 0)

Assert.Equal(20.0, a.X, precision: 6);
Assert.Equal(0.0, a.Y, precision: 6);
Assert.Equal(23.0, b.X, precision: 6); // original (3,7) + the same snapped (20, 0) delta
Assert.Equal(7.0, b.Y, precision: 6);
}

[Fact]
public void EndMultiDrag_StopsFurtherMovement()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(0, 0, "Hello");
editor.SetMultiSelection(new[] { element });
editor.BeginMultiDrag(0, 0);
editor.MultiDragTo(10, 10);

editor.EndMultiDrag();
editor.MultiDragTo(999, 999);

Assert.Equal(10.0, element.X, precision: 6); // unchanged by the post-EndMultiDrag call
Assert.False(editor.IsMultiDragging);
}

[Fact]
public void BeginMultiDrag_NothingMultiSelected_IsANoOp()
{
var editor = CreateEditor(out _);
editor.AddStaticText(0, 0, "Hello");

editor.BeginMultiDrag(0, 0);

Assert.False(editor.IsMultiDragging);
}
}

+ 411
- 11
code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs 查看文件

@@ -30,6 +30,27 @@ public sealed class TemplateCanvasControl : Control
/// 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>
@@ -95,11 +116,24 @@ public sealed class TemplateCanvasControl : Control
/// property existed.</summary>
public bool IsInteracting =>
_editor.IsDragging || _editor.IsRotating || _editor.IsResizingWidth || _addressControlDragOffset is not null
|| _isResizingAddressControl || _isRotatingAddressControl;
|| _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
@@ -228,11 +262,16 @@ public sealed class TemplateCanvasControl : Control
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);
}
@@ -326,7 +365,14 @@ public sealed class TemplateCanvasControl : Control
continue;
}

DrawElement(g, transform, item.Text, state, isSelected: ReferenceEquals(item.Text, _editor.Selected));
// 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;
}

@@ -334,7 +380,8 @@ public sealed class TemplateCanvasControl : Control
g,
transform,
item.Control!,
isSelected: ReferenceEquals(item.Control, _selectedAddressControl));
isPrimarySelected: ReferenceEquals(item.Control, _selectedAddressControl),
isMultiSelected: _multiSelectedAddressControls.Contains(item.Control!));
}

if (_editor.Selected is not null)
@@ -342,6 +389,29 @@ public sealed class TemplateCanvasControl : Control
DrawResizeHandle(g, transform, _editor.Selected);
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
@@ -391,6 +461,234 @@ public sealed class TemplateCanvasControl : Control
_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 top = control.Y + MaxLineFontSize(control);
var bottom = control.Y - control.Height;
var left = control.X;
var right = control.X + control.Width;

double elMinX, elMinY, elMaxX, elMaxY;
if (control.RotationAngle == 0)
{
(elMinX, elMinY, elMaxX, elMaxY) = (left, bottom, right, top);
}
else
{
var corners = new[] { (left, bottom), (right, bottom), (left, top), (right, top) };
elMinX = double.MaxValue;
elMinY = double.MaxValue;
elMaxX = double.MinValue;
elMaxY = double.MinValue;
foreach (var (cornerX, cornerY) in corners)
{
var (rx, ry) = PointRotation.RotateAroundPivot(cornerX, cornerY, control.BoxCenter, control.RotationAngle);
elMinX = Math.Min(elMinX, rx);
elMaxX = Math.Max(elMaxX, rx);
elMinY = Math.Min(elMinY, ry);
elMaxY = Math.Max(elMaxY, ry);
}
}

if (elMinX <= maxX && elMaxX >= minX && elMinY <= maxY && elMaxY >= minY)
{
result.Add(control);
}
}

return result;
}

/// <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)
@@ -585,7 +883,8 @@ public sealed class TemplateCanvasControl : Control
/// 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 isSelected)
private void DrawAddressControl(
Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isPrimarySelected, bool isMultiSelected)
{
GraphicsState? savedState = null;
if (control.RotationAngle != 0)
@@ -603,7 +902,7 @@ public sealed class TemplateCanvasControl : Control

try
{
DrawAddressControlUnrotated(g, transform, control, isSelected);
DrawAddressControlUnrotated(g, transform, control, isPrimarySelected, isMultiSelected);
}
finally
{
@@ -614,8 +913,17 @@ public sealed class TemplateCanvasControl : Control
}
}

private void DrawAddressControlUnrotated(Graphics g, CanvasViewTransform transform, AddressControlLayout control, bool isSelected)
/// <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);
@@ -623,8 +931,8 @@ public sealed class TemplateCanvasControl : Control
var (left, top) = transform.ToPixels(control.X, control.Y + MaxLineFontSize(control));
var (right, bottom) = transform.ToPixels(control.X + control.Width, control.Y - control.Height);
var box = RectangleF.FromLTRB((float)left, (float)top, (float)right, (float)bottom);
g.DrawRectangle(isSelected ? selectedPen : borderPen, box.X, box.Y, box.Width, box.Height);
if (isSelected)
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":
@@ -661,7 +969,7 @@ public sealed class TemplateCanvasControl : Control
using var brush = new SolidBrush(System.Drawing.Color.FromArgb(line.Color.R, line.Color.G, line.Color.B));
g.DrawString(text, font, brush, (float)drawX, (float)drawY);

if (isSelected && i == _selectedAddressLineIndex)
if (isPrimarySelected && i == _selectedAddressLineIndex)
{
using var linePen = new Pen(System.Drawing.Color.MediumSeaGreen, 1);
g.DrawRectangle(
@@ -903,6 +1211,12 @@ public sealed class TemplateCanvasControl : Control
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);

@@ -929,6 +1243,27 @@ public sealed class TemplateCanvasControl : Control
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;
@@ -962,13 +1297,48 @@ public sealed class TemplateCanvasControl : Control
return;
}

var hit = _editor.TrySelectAt(x, y);
if (hit)
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);
}
@@ -1052,6 +1422,18 @@ public sealed class TemplateCanvasControl : Control
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;
@@ -1059,6 +1441,15 @@ public sealed class TemplateCanvasControl : Control

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);
@@ -1119,8 +1510,17 @@ public sealed class TemplateCanvasControl : Control
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;


+ 26
- 0
code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs 查看文件

@@ -1045,6 +1045,18 @@ public sealed class TemplateDesignerForm : Form
/// properties-panel edit never disagree about the element's current values.</summary>
private void RefreshPropertiesPanel()
{
// Sprint 10, "Select multiple elements at once on the canvas": while two or more items
// are selected together, per-item property editing is disabled outright rather than
// showing one arbitrary member's values (which would silently mislead about what's
// actually selected, and editing them would only ever apply to that one item despite
// looking like a bulk edit). Align/distribute (the next story) operates on the whole set
// without going through these per-item fields at all.
if (_canvas.IsMultiSelectionActive)
{
_propertiesPanel.Enabled = false;
return;
}

var selected = _canvas.SelectedElement;
var selectedAddressControl = _canvas.SelectedAddressControl;
var selectedAddressLine = _canvas.SelectedAddressLine;
@@ -1216,6 +1228,20 @@ public sealed class TemplateDesignerForm : Form
// built .exe. TextElementLayout.DisplayText already generalizes this exact concatenation
// (literal runs verbatim, field runs as `{ColumnName}`) for the static/dynamic/mixed
// cases uniformly, so use it here directly instead of re-deriving the old two-case logic.
// Sprint 10, "Select multiple elements at once on the canvas": checked first since an
// active multi-selection always leaves the single-selection fields below null (see
// CanvasElementEditor.MultiSelected's remarks) — showing "No element selected" there would
// be actively wrong, not just uninformative. Delete is disabled rather than extended to a
// bulk operation: multi-item delete isn't in this story's scope, and a Delete keypress
// during multi-select already safely no-ops (RemoveSelectedElement only ever acts on the
// single-selection fields, both null here) rather than doing anything undefined.
if (_canvas.IsMultiSelectionActive)
{
_selectionLabel.Text = $"{_canvas.MultiSelectionCount} items selected.";
_deleteSelectedButton.Enabled = false;
return;
}

var selected = _canvas.SelectedElement;
if (selected is not null)
{


+ 2
- 1
logs/technical_debt_log.md 查看文件

@@ -13,4 +13,5 @@ Append-only log of known technical debt. Maintained by `.claude/agents/qa-tech-d
| 2026-10-19 | Address Control lines rendered on the designer canvas (`TemplateCanvasControl.DrawAddressControl`) and, since Sprint 7, on the new record-accurate preview panel (`TemplatePreviewControl`) visually overlap when a control uses a small font size with the default 1.25 line-spacing multiplier (observed with `sample-envelope-template3.xml`'s 8pt lines against the real sample CSV): each line's GDI+ `MeasureString` height is taller than the `FontSize * LineSpacingMultiplier` row height the baseline math advances by, so consecutive lines' bounding boxes visually overlap on screen even though their baselines are correctly spaced per `TEMPLATE_FORMAT.md`. Confirmed via live built-`.exe` screenshots of both surfaces during Sprint 7 batch 1 verification — present identically on the pre-existing (Sprint 6) design canvas, so this is not a Sprint 7 regression, just newly re-observed because a second surface now shares the same measurement approach. | Unintentional (side effect of using GDI+ `MeasureString`'s full line height as a stand-in for the real Debenu-rendered glyph height, which the code's own remarks already flag as "a design-time visual approximation... not a guarantee of pixel-for-point parity with the final PDF") | Low (cosmetic, canvas/preview-only — the actual PDF render path, `DebenuPdfRenderer`/`RenderEngine`, does not use this measurement at all and is unaffected; does not block reading resolved text, just makes tightly-spaced small-font address blocks visually crowded in the designer) | Open | Not fixed as part of Sprint 7 (out of scope for both the preview-panel and snap-to-grid stories). Candidate direction: derive each line's row height from the same font's ascent/descent (or a smaller line-height fraction of `MeasureString`) instead of the raw measured string height, or accept a documented minimum recommended `LineSpacingMultiplier` for small fonts. Revisit if an operator reports this as more than cosmetic. **Product-owner review confirmation (Sprint 7 Review, 2026-10-19):** Impact/status agreed as logged — Low, Open, non-blocking. Independently confirmed the root cause is confined to `TemplateCanvasControl`/`TemplatePreviewControl`'s GDI+ measurement path and that neither `DebenuPdfRenderer` nor `RenderEngine` (the real PDF render path) references this measurement at all, so no print-output correctness is at risk. No pushback on leaving this open; agree it should not block Sprint 8 planning. |
| 2026-09-04 | `EnvelopeRenderer.Cli` only read `DEBENU_LICENSE_KEY` from the process environment. That's fine for `dotnet run --project ... --` (the CLI inherits the invoking shell's env directly), but `EnvelopeRenderer.Desktop` launches the CLI as a child process, which only inherits whatever environment variables were already present in whatever launched the desktop app itself (a double-clicked `.exe` or Start Menu shortcut typically has none) — so every desktop-launched render failed with Debenu error 999 regardless of a valid key existing on disk. Real-user-reported: the operator correctly guessed a `key.txt` dropped next to the exe should work (matching how the CLI's own test helper already resolved keys), but production code had no such fallback. This is a real Definition-of-Done verification gap from Sprint 1 Batches 4-5: the "real success run" verification used an in-process test harness with the env var set directly in that process, never the actual built `.exe` launched the way an operator would, so the gap wasn't caught before Sprint Review. | Unintentional | High (silently broke the desktop app's core success path for any non-`dotnet run` launch) | Resolved | Added `DebenuLicenseKeyResolver` (`code/src/EnvelopeRenderer.Cli/DebenuLicenseKeyResolver.cs`) to the shipped CLI: env var first, then a `key.txt` walked up from the executable's own directory — the same rule the test-only helper already used, now shared via delegation instead of duplicated. Documented in `CLI_CONTRACT.md`'s "Debenu license key" section and `code/README.md`'s desktop-app instructions. Verified by running the actual built `EnvelopeRenderer.Cli.exe` from the Desktop app's own output folder with no environment variable set at all, `key.txt` sitting next to it: exit `0`, valid 1.3 MB `%PDF-1.4` output, all 392 records. 5 new unit tests added (`DebenuLicenseKeyResolverTests.cs`); full suite 111/111 passing. |
| 2026-10-19 | Post-Sprint-7-review, the plain editing canvas (`TemplateCanvasControl`/`CanvasElementEditor`) now deliberately rotates every element — static and dynamic/mixed alike — around its own bounding-box center (`RotationPivotCalculator.ComputeForCanvasEditing`), instead of the Sprint 6 fixed-anchor rule (`RotationPivotCalculator.Compute(isDynamic, ...)`) it previously shared uniformly with the real render and the new Sprint 7 preview panel. The editing canvas only ever draws an element's literal authored `{ColumnName}` bracket-token text (`TextElementLayout.DisplayText`), never a per-record resolved value, so the record-to-record text-width drift the fixed-anchor rule exists to prevent cannot occur there — applying it anyway just made a dynamic/mixed element's rotate-handle drag swing around a corner instead of spinning in place, a jarring interactive inconsistency the human product owner asked to have fixed after using the Sprint 7 increment. The real render (`RotatedTextAnchorCalculator`/`DebenuPdfRenderer`) and the new preview panel (`TemplatePreviewControl`/`TemplatePreviewBuilder`) are intentionally untouched and still call `RotationPivotCalculator.Compute` with the real `isDynamic` value, so the original 2026-10-09 record-drift defect fix is fully preserved where it actually matters. See the dated note added to the "Keep rotated dynamic and mixed-content fields positioned consistently across records" story in `backlog/epics/02_template_designer_gui_foundation.md` for the full reasoning and human product-owner approval record. | Deliberate (a considered, approved trade-off decoupling a purely cosmetic/interactive editing-canvas behavior from a record-accuracy rule that surface was never actually subject to — not a regression or an oversight) | Low (the editing canvas was never a record-accuracy surface for rotation to begin with, since it never draws resolved per-record text; the real render and record-accurate preview panel, where the original defect actually mattered, are unaffected and keep the exact same fixed-anchor behavior) | Logged (deliberate design decision, not a defect to resolve) | Not applicable — this row documents a considered, already-implemented, and approved design decision for future reference (e.g. if a future story ever gives the editing canvas its own resolved-per-record preview capability, this decoupling would need to be revisited). |
| 2026-10-26 | Sprint 8 ("Rotate the whole Address Control...") extracted its *new* rigid-group-rotation and drag-handle math into framework-free, unit-tested `EnvelopeRenderer.Desktop.Core.Design` classes (`AddressControlLayout.BoxCenter`, `PointRotation`, `AddressControlRotateHandle`), but the *pre-existing* (Sprint 6) Address Control interaction logic it builds on top of — `TemplateCanvasControl.HitTestAddressControl`, `HitTestAddressResizeHandle`, and the whole-control move/resize mouse handlers — still lives directly in the WinForms `Views` project rather than a `CanvasElementEditor`-equivalent for Address Controls, so it remains untestable by `EnvelopeRenderer.Desktop.Tests` (verified only via a live built-`.exe` reflection-driven smoke, not a unit test) unlike the standalone-element equivalents (`CanvasElementEditor.HitTest`/`HandlePosition`/`RotateDragTo`, which are unit tested). This is a proportional, in-scope decision for this sprint (retroactively refactoring Sprint 6's already-shipped, already-smoke-verified move/resize/hit-test code was not part of either committed story), not an oversight, but it leaves a real, growing architectural inconsistency between the two element kinds' testability. | Unintentional (a natural side effect of adding new, better-architected code alongside older code that predates the pattern, not a deliberate call to leave the old code as-is) | Low (no known behavioral defect — the smoke-tested code paths work correctly per this sprint's live verification; purely a testability/maintainability gap, and the two newly-added rotation classes themselves are fully unit tested) | Open | Candidate direction: extract `TemplateCanvasControl`'s Address-Control-specific hit-test/drag/resize state and math into a new `AddressControlEditor` (Desktop.Core), mirroring `CanvasElementEditor`'s existing role for standalone elements, so all four interaction kinds (select, move, resize, rotate) are unit tested consistently. Not required by any currently-committed story; revisit if Address Control interaction logic grows further (e.g. multi-select epic 6 work) or a live-verified defect is found in it. |
| 2026-10-26 | Sprint 8 ("Rotate the whole Address Control...") extracted its *new* rigid-group-rotation and drag-handle math into framework-free, unit-tested `EnvelopeRenderer.Desktop.Core.Design` classes (`AddressControlLayout.BoxCenter`, `PointRotation`, `AddressControlRotateHandle`), but the *pre-existing* (Sprint 6) Address Control interaction logic it builds on top of — `TemplateCanvasControl.HitTestAddressControl`, `HitTestAddressResizeHandle`, and the whole-control move/resize mouse handlers — still lives directly in the WinForms `Views` project rather than a `CanvasElementEditor`-equivalent for Address Controls, so it remains untestable by `EnvelopeRenderer.Desktop.Tests` (verified only via a live built-`.exe` reflection-driven smoke, not a unit test) unlike the standalone-element equivalents (`CanvasElementEditor.HitTest`/`HandlePosition`/`RotateDragTo`, which are unit tested). This is a proportional, in-scope decision for this sprint (retroactively refactoring Sprint 6's already-shipped, already-smoke-verified move/resize/hit-test code was not part of either committed story), not an oversight, but it leaves a real, growing architectural inconsistency between the two element kinds' testability. | Unintentional (a natural side effect of adding new, better-architected code alongside older code that predates the pattern, not a deliberate call to leave the old code as-is) | Low (no known behavioral defect — the smoke-tested code paths work correctly per this sprint's live verification; purely a testability/maintainability gap, and the two newly-added rotation classes themselves are fully unit tested) | Open | Candidate direction: extract `TemplateCanvasControl`'s Address-Control-specific hit-test/drag/resize state and math into a new `AddressControlEditor` (Desktop.Core), mirroring `CanvasElementEditor`'s existing role for standalone elements, so all four interaction kinds (select, move, resize, rotate) are unit tested consistently. Not required by any currently-committed story; revisit if Address Control interaction logic grows further (e.g. multi-select epic 6 work) or a live-verified defect is found in it. **2026-10-27 update:** this row's own trigger occurred — Sprint 10 Batch 2 ("Select multiple elements at once on the canvas") added `AddressControlsInRect`/`ApplyMultiDragToAddressControls`/`ToggleAddressControlMultiSelect` directly to `TemplateCanvasControl`, following the same untested-WinForms-only pattern rather than doing the extraction, a deliberate choice (the standalone-element half of the exact same feature *is* fully unit tested in `CanvasElementEditor`, so multi-select's core logic has real test coverage even though the Address Control half doesn't) rather than scope-creeping an unrelated refactor into an already-large story. Still Open; the case for the extraction is now stronger with two features built on the untested side. |
| 2026-10-27 | A standalone canvas element's selection highlight border (`TemplateCanvasControl.DrawElement`'s `isSelected` rectangle) visibly wraps only part of a multi-word `DisplayText` (e.g. the default "Static text" highlights only "Static") rather than the full rendered string, for every plain (non-boxed) element regardless of selection mode — discovered via a live built-form screenshot while verifying Sprint 10 Batch 2's multi-select highlighting, and confirmed pre-existing (present identically for the ordinary single-selection case, `MeasureElement`/`DrawElement`'s non-box branch is unmodified by Batch 2). Root cause not yet confirmed by code inspection beyond the immediate suspect: `MeasureElement`'s non-boxed path calls `Graphics.MeasureString(text, font)` (the no-layout-rectangle overload), a GDI+ API with a long-documented history of measuring narrower than what `Graphics.DrawString` actually renders for certain text/hint combinations — plausible but not yet isolated with a minimal repro. | Unintentional (a GDI+ measurement/render mismatch, not anything introduced by this sprint's own drawing changes) | Low (cosmetic, canvas-only design-time approximation — does not affect hit-testing correctness, which was unaffected in this session's own multi-select verification, nor the real PDF render path, which never calls this method) | Open | Not investigated further or fixed as part of Sprint 10 Batch 2 (out of scope for the multi-select story; affects the pre-existing single-selection highlight equally). Candidate direction: try `Graphics.MeasureString` with a generously large layout rectangle (rather than the no-rectangle overload) or `TextRenderer.MeasureText`, and compare against `DrawString`'s actual rendered extent on a real screenshot. Revisit if an operator reports the selection highlight looking wrong, or before any story that depends on this measurement being visually accurate (e.g. further canvas polish). |

+ 4
- 1
state.md 查看文件

@@ -20,7 +20,9 @@

**Post-Sprint-10-Batch-1 user-reported regression fix (2026-10-27, handled outside formal ceremony, mid-Sprint-10):** User reported that clicking on and moving a dynamic placeholder had become "not smooth any more." Root cause found by direct `Stopwatch` instrumentation of `TemplateCanvasControl.OnMouseDown` (not guessed): `SelectionChanged` fired unconditionally on every left-click, including a click on an already-selected element (the normal way to start a drag), and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time — a cost (~40-60ms/click, measured) that only became perceptible once this sprint's added property-panel rows made the refresh heavier. Fixed by only raising `SelectionChanged` when the pre-click and post-click selection identity actually differ (`RaiseSelectionChangedIfDifferent`). Live-verified with the same instrumented harness: an already-selected element's second click dropped from ~50ms to 0.295ms; a genuine selection change still pays the warranted refresh unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix isolated to the WinForms-only `TemplateCanvasControl`). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`.

**Next action:** Continue Sprint 10 as `dev-team`: Batch 2 "Select multiple elements at once on the canvas" (5 pts, epic 6, no dependency but sequenced next as the epic-6 pair's foundation), then Batch 3 "Align and distribute multiple elements" (5 pts, depends on Batch 2), per `backlog/sprints/sprint-10.md`. Key design note for Batch 2, not to be rediscovered mid-sprint: add multi-select as an *additive* layer alongside the existing single-selection state (`CanvasElementEditor.Selected` for standalone elements, `TemplateCanvasControl._selectedAddressControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected. This is a natural mid-sprint checkpoint (a large, first-of-its-kind feature remains, plus the human product owner just gave live-build feedback three times this session) — worth confirming with the user before continuing into the multi-select implementation. Still open, non-blocking: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred to Sprint 11); "Undo and redo layout changes" (epic 6, 13 pts); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision.
**Sprint 10 Batch 2 complete (2026-10-27):** "Select multiple elements at once on the canvas" (5/13 points) shipped — a strictly additive multi-select layer (`CanvasElementEditor.MultiSelected` for standalone elements, `TemplateCanvasControl._multiSelectedAddressControls` for Address Controls) alongside the existing single-selection fields, collapsing back to ordinary single-select whenever 0-1 items end up selected so no existing code path changed behavior. Rubber-band selection and modifier-click toggle both work across a mixed selection of elements and Address Controls; group drag moves the whole set by one shared delta, preserving relative offsets; every selected item is visibly highlighted; the properties panel shows "N items selected" and disables per-item editing. Full suite 364/364 -> 376/376 (Desktop.Core; CLI unaffected). Live-verified via a reflection-driven built-form harness (rubber-band, toggle, group-drag delta math, panel state, a real canvas screenshot) with one disclosed caveat (`Control.ModifierKeys` has no public setter, so modifier-click was verified by calling the same private toggle method directly rather than through a simulated key-press) and one pre-existing, unrelated cosmetic gap found and logged rather than fixed in-scope (a selection highlight border under-wraps a multi-word display string — `logs/technical_debt_log.md`, 2026-10-27). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`.

**Next action:** Continue Sprint 10 as `dev-team`: Batch 3, "Align and distribute multiple elements" (5 pts, epic 6, depends on Batch 2's multi-selection now existing), per `backlog/sprints/sprint-10.md`. This completes the sprint's committed scope (13/13 points) once done — plan for Sprint Review and Retrospective to follow immediately after, per the standard state-driven handoff. Still open, non-blocking: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred to Sprint 11); "Undo and redo layout changes" (epic 6, 13 pts); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision; two technical debt items from Batch 2's verification (Address-Control interaction logic still untested WinForms-only, and the selection-highlight width cosmetic gap), both Open/Low and non-blocking.

## Phase reference

@@ -95,3 +97,4 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-10-27 | 2 - Sprint planning | `scrum-master` facilitated with `product-owner`/`dev-team` input. Capacity signal now has nine data points (20, 19, 18, 18, 15, 18, 18, 13, 13). Committed 13 points: the 3-point indicator story plus epic 6's multi-select/align pair (10 pts), finally pulled after being deferred twice (Sprint 8, Sprint 9) for fresher requests that no longer apply. Reasoned explicitly that pairing a small, additive story with one large/novel story (multi-select's new selection model) does not trip the established "two large/novel stories" caution, since only one side carries that risk profile. Recorded in `backlog/sprints/sprint-10.md`; full reasoning in `backlog/backlog.md`'s Sprint 10 planning outcome note. |
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 1, "Add a live wrap/clip indicator for text elements" (3/13 points). New framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel. Mid-batch, the human product owner raised two live-build issues, addressed together: (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely; `HasBox` now means "Width is set" alone, `Height` independent and optional (new `HasHeightClip`), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path. Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) confirmed auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. |
| 2026-10-27 | 3 - Sprint execution (ad hoc, outside formal ceremony) | User reported that clicking on and moving a dynamic placeholder had become "not smooth any more." Root cause found by directly instrumenting `TemplateCanvasControl.OnMouseDown` with `Stopwatch` timers rather than guessing: `SelectionChanged` fired unconditionally on every left-click — including a click on an already-selected element, the normal way to begin dragging it — and `TemplateDesignerForm`'s handler runs the full un-gated `RefreshPropertiesPanel()` every time, a cost (~40-60ms/click, measured) that only became perceptible once this sprint's added property-panel rows made the refresh heavier. Fixed by capturing the pre-click selection identity and only raising `SelectionChanged` when it actually changed (`RaiseSelectionChangedIfDifferent`). Live-verified with the same instrumented reflection harness: an already-selected element's second click dropped from ~50ms to 0.295ms end-to-end in `OnMouseDown`, while a genuine selection change still correctly pays the warranted refresh cost unchanged. Full suite re-run: Desktop.Core 364/364, CLI 124/124 (fix isolated to the WinForms-only `TemplateCanvasControl`, which has no automated test project). Documented in `backlog/epics/02_template_designer_gui_foundation.md` and `backlog/sprints/sprint-10.md`. |
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 2, "Select multiple elements at once on the canvas" (5/13 points). Additive multi-select layer alongside existing single-selection state (`CanvasElementEditor.MultiSelected`, `TemplateCanvasControl._multiSelectedAddressControls`), collapsing to ordinary single-select at 0-1 items so every existing code path is unaffected. Rubber-band selection (rotation-aware AABB intersection) and modifier-click toggle both work across mixed elements/Address Controls; group drag moves the set by one shared delta preserving relative offsets (snapping the delta once, not each item's position independently); every selected item is visibly highlighted; properties panel shows "N items selected," disabled for per-item editing. 12 new Desktop.Core tests, full suite 364/364 -> 376/376. Live-verified via reflection-driven built-form harness including a real canvas screenshot; one disclosed caveat (`ModifierKeys` has no public setter, so modifier-click verified via direct private-method call) and one unrelated pre-existing cosmetic gap found and logged, not fixed in-scope (selection-highlight width under-wrap). Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/06_layout_efficiency_and_operator_tooling.md`. |

Loading…
取消
儲存

Powered by TurnKey Linux.