diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 9f6b503..ee0ad2b 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -1112,6 +1112,73 @@ public static class EditSmoke
$"ValueText='{choiceRow.ValueText}' IsMixed={choiceRow.IsMixed}");
}
+ // 15-a0000000) 끄는 동안 치수·좌표가 캔버스에 보인다.
+ //
+ // 전에는 숫자가 300px 떨어진 인스펙터에만 떴다 — 끌고, 손 떼고, 오른쪽을 보고, 다시 끈다.
+ // 간격 수치 칩은 균등 스냅이 성립할 때만 나타나므로 "지금 몇 px 인가"는 알 방법이 없었다.
+ {
+ var badge = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
+ badge.AddControlAt("Label", new Point(80, 80));
+ var one = badge.Pages[0].Controls[0];
+ badge.Selection.SetSingle(one);
+ Check("치수 칩: 평소에는 안 보인다", !badge.Overlay.ShowDragBadge);
+
+ var world = badge.WorldBoundsOf(one);
+ var grab = new Point(world.X + world.Width / 2, world.Y + world.Height / 2);
+ var noSnap = new PointerContext(false, false, true, 1);
+ badge.Interaction.PointerDown(grab, noSnap);
+ badge.Interaction.PointerMove(new Point(grab.X + 30, grab.Y + 10), noSnap);
+ Check("치수 칩: 끄는 동안 좌표가 보인다",
+ badge.Overlay.ShowDragBadge && badge.Overlay.DragBadgeText.StartsWith("X ", StringComparison.Ordinal),
+ badge.Overlay.DragBadgeText);
+
+ badge.Interaction.PointerUp(new Point(grab.X + 30, grab.Y + 10), noSnap);
+ Check("치수 칩: 놓으면 사라진다", !badge.Overlay.ShowDragBadge);
+
+ // 크기 조절일 때는 크기를 보여 준다 — 지금 바꾸고 있는 값이 그것이다
+ badge.Selection.SetSingle(one);
+ var bounds = badge.WorldBoundsOf(one);
+ var handles = ViewModels.SelectionOverlayViewModel.HandlePositions(bounds);
+ badge.Interaction.PointerDown(handles[4], noSnap); // 우하단
+ badge.Interaction.PointerMove(new Point(handles[4].X + 25, handles[4].Y + 12), noSnap);
+ Check("치수 칩: 크기 조절 중에는 크기가 보인다",
+ badge.Overlay.ShowDragBadge && badge.Overlay.DragBadgeText.Contains('×'),
+ badge.Overlay.DragBadgeText);
+ badge.Interaction.PointerUp(new Point(handles[4].X + 25, handles[4].Y + 12), noSnap);
+ }
+
+ // 15-a000000) 패널 안에 그린 마퀴는 그 안의 것을 고른다.
+ //
+ // 전에는 마퀴가 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고
+ // 패널 안쪽을 끌면 패널만 잡히거나 아무것도 안 잡혔다 —
+ // 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
+ // 컨테이너 자식은 전체 컨트롤의 18.3%(실측)라 드문 경우가 아니다.
+ {
+ var mq = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
+ mq.AddControlAt("CheckBox", new Point(100, 100));
+ mq.AddControlAt("CheckBox", new Point(100, 140));
+ mq.Selection.Set(mq.Pages[0].Controls.ToList(), mq.Pages[0].Controls[0]);
+ mq.GroupSelection();
+ var box = mq.Pages[0].Controls.OfType().First();
+ var boxWorld = mq.WorldBoundsOf(box);
+
+ // ① 패널 안쪽을 끈다 — 자식이 잡혀야 한다
+ var inside = new Rect(boxWorld.X + 2, boxWorld.Y + 2,
+ Math.Max(1, boxWorld.Width - 4), Math.Max(1, boxWorld.Height - 4));
+ var innerHits = mq.MarqueeHits(inside);
+ Check("마퀴: 패널 안에 그리면 안의 것이 잡힌다",
+ innerHits.Count == box.Children.Count
+ && innerHits.All(h => ReferenceEquals(h.Parent, box)),
+ $"{innerHits.Count}개 (패널 자식 {box.Children.Count}개)");
+
+ // ② 패널을 걸치도록 끈다 — 예전처럼 패널 자체가 잡혀야 한다
+ var across = new Rect(boxWorld.X - 20, boxWorld.Y - 20, boxWorld.Width + 40, 30);
+ var outerHits = mq.MarqueeHits(across);
+ Check("마퀴: 패널에 걸치면 패널이 잡힌다(기존 동작 유지)",
+ outerHits.Count == 1 && ReferenceEquals(outerHits[0], box),
+ $"{outerHits.Count}개 — {string.Join(",", outerHits.Select(h => h.Type))}");
+ }
+
// 15-a00000) 태그 행이 사실대로 말하는가.
{
// 카탈로그에 없는 이름은 표식이 붙어야 한다 — 그러나 막지는 않는다.
diff --git a/src/SheetMe.Designer/Services/InteractionController.cs b/src/SheetMe.Designer/Services/InteractionController.cs
index 81f8b77..a2581c9 100644
--- a/src/SheetMe.Designer/Services/InteractionController.cs
+++ b/src/SheetMe.Designer/Services/InteractionController.cs
@@ -233,20 +233,21 @@ public sealed class InteractionController
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
+ designer.Overlay.ClearDragBadge();
break;
case Mode.Resize:
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
+ designer.Overlay.ClearDragBadge();
break;
case Mode.Marquee:
var rect = RectFrom(downPoint, world);
designer.Overlay.UpdateMarquee(null);
- var hits = designer.ControlsIntersecting(rect)
- .SelectMany(designer.GroupMatesOf)
- .Distinct()
- .ToList();
+ // 마퀴 전용 규칙 — 그룹은 넓히되 컨테이너 자식은 부모로 되돌리지 않는다.
+ // GroupMatesOf 를 그대로 쓰면 패널 안쪽을 정확히 골라 놓고도 결과가 패널 하나가 된다.
+ var hits = designer.MarqueeHits(rect);
if (ctx.Ctrl || ctx.Shift)
{
var merged = designer.Selection.Items.Union(hits).ToList();
@@ -285,6 +286,7 @@ public sealed class InteractionController
{
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
+ designer.Overlay.ClearDragBadge();
mode = Mode.None;
origBounds.Clear();
downControl = null;
@@ -443,6 +445,11 @@ public sealed class InteractionController
}
designer.Selection.NotifyBoundsChanged();
+ // 끄는 동안 지금 값을 보여 준다 — 숫자가 인스펙터에만 있으면 끌다 말고 오른쪽을 봐야 한다
+ if (designer.SelectionWorldBounds() is { } movedBounds)
+ {
+ designer.Overlay.SetDragBadge(movedBounds, resizing: false);
+ }
}
private void DoResize(Point world, PointerContext ctx)
@@ -509,6 +516,10 @@ public sealed class InteractionController
}
designer.Selection.NotifyBoundsChanged();
+ if (designer.SelectionWorldBounds() is { } sizedBounds)
+ {
+ designer.Overlay.SetDragBadge(sizedBounds, resizing: true);
+ }
}
/// 드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index 3a273f1..fcdffbd 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -864,21 +864,70 @@ public sealed class DesignerViewModel : ViewModelBase
var result = new List();
foreach (var page in Pages)
{
- foreach (var vm in page.Controls)
- {
- if (vm.Model.Hidden || vm.Model.Locked)
- {
- continue;
- }
- if (worldRect.IntersectsWith(WorldBoundsOf(vm)))
- {
- result.Add(vm);
- }
- }
+ CollectIntersecting(page.Controls, worldRect, result);
}
return result;
}
+ ///
+ /// 마퀴에 걸리는 것을 모은다 — 컨테이너 안에 그린 마퀴는 그 안의 것을 고른다.
+ ///
+ /// 전에는 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고 패널 안쪽을 끌면
+ /// 패널만 잡히거나 아무것도 안 잡혔다 — 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
+ /// 컨테이너 자식은 전체 컨트롤의 18.3%(실측 18,749/컨테이너 4,099개)라 드문 경우가 아니다.
+ ///
+ /// 규칙은 '담김'이다. 마퀴가 컨테이너 사각형 안에 들어가 있으면
+ /// 사용자는 그 안의 것을 고르려는 것이다(내 상자가 패널보다 작으니까).
+ /// 걸치기만 하면 예전처럼 컨테이너 자체가 잡힌다 — 패널을 통째로 고르는 조작이 그대로 남는다.
+ /// 모드도 수정키도 없이 그린 모양만으로 갈리므로 예측할 수 있다.
+ ///
+ private void CollectIntersecting(IEnumerable controls, Rect worldRect,
+ List result)
+ {
+ foreach (var vm in controls)
+ {
+ if (vm.Model.Hidden || vm.Model.Locked)
+ {
+ continue;
+ }
+ var bounds = WorldBoundsOf(vm);
+ if (!worldRect.IntersectsWith(bounds))
+ {
+ continue;
+ }
+ if (vm is ContainerViewModel container && bounds.Contains(worldRect))
+ {
+ CollectIntersecting(container.Children, worldRect, result);
+ continue;
+ }
+ result.Add(vm);
+ }
+ }
+
+ ///
+ /// 마퀴가 최종적으로 고를 것 — 그룹은 통째로 넓히되 컨테이너 자식은 그대로 둔다.
+ ///
+ /// 클릭 선택은 를 쓰는데 그것은 자식을 부모 컨테이너로 되돌린다
+ /// (패널 안의 것을 집으면 패널이 움직여야 하므로 옳다). 마퀴에 그것을 그대로 쓰면
+ /// 안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어, 위 규칙이 아무 일도 하지 않는다.
+ ///
+ public List MarqueeHits(Rect worldRect)
+ {
+ var hits = ControlsIntersecting(worldRect);
+ var result = new List();
+ foreach (var vm in hits)
+ {
+ // 그룹(GroupId)은 페이지 최상위 개념이다 — 컨테이너 자식에는 붙지 않는다
+ if (vm.Parent is null && vm.Model.GroupId is not null)
+ {
+ result.AddRange(GroupMatesOf(vm));
+ continue;
+ }
+ result.Add(vm);
+ }
+ return result.Distinct().ToList();
+ }
+
/// 오버레이(선택 박스/핸들) 재계산
public void RefreshOverlay()
{
diff --git a/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs b/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs
index b3531d2..3ec05e1 100644
--- a/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs
@@ -116,6 +116,10 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
private double selW;
private double selH;
private bool showHandles;
+ private string dragBadgeText = string.Empty;
+ private bool showDragBadge;
+ private double dragBadgeX;
+ private double dragBadgeY;
private bool hasMarquee;
private double marX;
private double marY;
@@ -189,6 +193,43 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
/// 탭순서 배지 목록(탭순서 편집 모드 전용)
public ObservableCollection TabBadges { get; } = new();
+ ///
+ /// 끄는 동안 보여 주는 치수·좌표 — 드래그 중에만 있다.
+ ///
+ /// 전에는 숫자가 300px 떨어진 인스펙터에만 떴다. 라벨 폭을 종이 칸에 맞추려면
+ /// 끌고, 손 떼고, 오른쪽을 보고, 다시 끄는 왕복이 붙었다.
+ /// 간격 수치 칩은 균등 스냅이 성립하는 순간에만 나타나므로
+ /// "지금 이게 몇 px 인가"는 드래그 중에 알 방법이 없었다.
+ ///
+ public string DragBadgeText { get => dragBadgeText; private set => SetProperty(ref dragBadgeText, value); }
+
+ /// 치수 칩을 보일지
+ public bool ShowDragBadge { get => showDragBadge; private set => SetProperty(ref showDragBadge, value); }
+
+ /// 치수 칩 X(월드) — 선택 상자 좌하단 바깥
+ public double DragBadgeX { get => dragBadgeX; private set => SetProperty(ref dragBadgeX, value); }
+
+ /// 치수 칩 Y(월드)
+ public double DragBadgeY { get => dragBadgeY; private set => SetProperty(ref dragBadgeY, value); }
+
+ ///
+ /// 끄는 동안의 치수·좌표를 갱신한다 — 이동이면 좌표, 크기 조절이면 크기를 보여 준다.
+ /// 사용자가 지금 바꾸고 있는 값을 보여 주는 것이 요점이다.
+ ///
+ public void SetDragBadge(Rect bounds, bool resizing)
+ {
+ DragBadgeText = resizing
+ ? $"{Math.Round(bounds.Width)} × {Math.Round(bounds.Height)}"
+ : $"X {Math.Round(bounds.X)} · Y {Math.Round(bounds.Y)}";
+ // 선택 상자 아래에 둔다 — 위에 두면 위쪽으로 끌 때 커서가 칩을 밀고 다닌다
+ DragBadgeX = bounds.X;
+ DragBadgeY = bounds.Bottom + (6 * InverseZoom);
+ ShowDragBadge = true;
+ }
+
+ /// 드래그가 끝났다 — 칩을 치운다
+ public void ClearDragBadge() => ShowDragBadge = false;
+
///
/// 잠긴 컨트롤의 테두리 — 편집 크롬이라 종이에는 그려지지 않는다.
///
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index 49654ac..0dce641 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -175,7 +175,8 @@
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
-->