diff --git a/src/SheetMe.Core/Layout/AlignSolver.cs b/src/SheetMe.Core/Layout/AlignSolver.cs
new file mode 100644
index 0000000..822440e
--- /dev/null
+++ b/src/SheetMe.Core/Layout/AlignSolver.cs
@@ -0,0 +1,70 @@
+namespace SheetMe.Core.Layout;
+
+/// 정렬 방향 — 가로 3종, 세로 3종
+public enum AlignMode
+{
+ Left,
+ CenterH,
+ Right,
+ Top,
+ CenterV,
+ Bottom,
+}
+
+///
+/// 정렬 산술 — 항목 하나를 기준 사각형에 맞춘 새 자리를 낸다.
+///
+/// 왜 여기 있는가. 계산 자체는 짧지만 경계가 잔소리 같은 규칙 덩어리다 —
+/// 반올림 방향, 기준보다 넓은 항목, 용지가 아래 페이지라 원점이 큰 경우.
+/// 이걸 디자이너 쪽에서 검사하려면 케이스마다 실행취소 스냅샷을 하나씩 먹는데(스모크의 예산이 있다),
+/// 순수 함수로 두면 단위 테스트가 공짜다.
+///
+/// 를 그대로 쓴다 — 새 타입을 만들 이유가 없고,
+/// 스냅과 정렬이 같은 좌표 표현을 공유해야 결과가 갈라지지 않는다.
+///
+public static class AlignSolver
+{
+ #region Methods
+ ///
+ /// 을 에 맞춘 사각형(월드).
+ ///
+ /// 기준보다 항목이 넓으면 가운데 맞춤이 음수를 낸다 — 그대로 둔다.
+ /// 가운데에 두려면 그래야 하고, 여기서 잘라 붙이면 "가운데 맞춤을 눌렀는데 가운데가 아니다"가 된다.
+ ///
+ public static SnapRect Apply(SnapRect item, SnapRect reference, AlignMode mode) => mode switch
+ {
+ AlignMode.Left => item with { X = Round(reference.Left) },
+ AlignMode.Right => item with { X = Round(reference.Right - item.W) },
+ AlignMode.CenterH => item with { X = Round(reference.Left + (reference.W - item.W) / 2) },
+ AlignMode.Top => item with { Y = Round(reference.Top) },
+ AlignMode.Bottom => item with { Y = Round(reference.Bottom - item.H) },
+ AlignMode.CenterV => item with { Y = Round(reference.Top + (reference.H - item.H) / 2) },
+ _ => item,
+ };
+
+ /// 문자열 명령을 방향으로 — 모르는 값이면 null(호출부가 아무 일도 하지 않는다)
+ public static AlignMode? Parse(string mode) => mode switch
+ {
+ "Left" => AlignMode.Left,
+ "Right" => AlignMode.Right,
+ "CenterH" => AlignMode.CenterH,
+ "Top" => AlignMode.Top,
+ "Bottom" => AlignMode.Bottom,
+ "CenterV" => AlignMode.CenterV,
+ _ => null,
+ };
+
+ /// 세로 축 명령인가 — 여러 페이지에 걸친 선택에서 거부해야 할 것들
+ public static bool IsVertical(AlignMode mode)
+ => mode is AlignMode.Top or AlignMode.CenterV or AlignMode.Bottom;
+
+ ///
+ /// 좌표는 정수로 떨어뜨린다 — 저장 포맷(LegacyFormat)이 어차피 정수로 쓰므로
+ /// 여기서 정하지 않으면 "화면에선 맞았는데 다시 열면 1px 어긋남"이 된다.
+ ///
+ /// 기본 ToEven 을 쓰지 않는 이유: 322.5→322, 323.5→324 로 방향이 값에 따라 뒤집힌다.
+ /// 폭이 제각각인 라벨 다섯 개를 가운데 맞춤하면 어떤 건 322, 어떤 건 323 에 앉는다.
+ ///
+ private static double Round(double value) => Math.Round(value, MidpointRounding.AwayFromZero);
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Controls/LucideIcons.cs b/src/SheetMe.Designer/Controls/LucideIcons.cs
index c8fded4..6531287 100644
--- a/src/SheetMe.Designer/Controls/LucideIcons.cs
+++ b/src/SheetMe.Designer/Controls/LucideIcons.cs
@@ -84,9 +84,9 @@ public static class LucideIcons
["moon"] = """""",
["move-horizontal"] = """ """,
["move-vertical"] = """ """,
- ["align-obj-top"] = """ """,
- ["align-obj-middle"] = """ """,
- ["align-obj-bottom"] = """ """,
+ ["align-obj-top"] = """ """,
+ ["align-obj-middle"] = """ """,
+ ["align-obj-bottom"] = """ """,
["distribute-h"] = """ """,
["distribute-v"] = """ """,
["square"] = """""",
@@ -94,9 +94,9 @@ public static class LucideIcons
["unlock"] = """ """,
["group"] = """ """,
["ungroup"] = """ """,
- ["align-obj-left"] = """ """,
- ["align-obj-center-h"] = """ """,
- ["align-obj-right"] = """ """,
+ ["align-obj-left"] = """ """,
+ ["align-obj-center-h"] = """ """,
+ ["align-obj-right"] = """ """,
["droplet"] = """""",
["rotate-cw"] = """ """,
["frame"] = """ """,
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index fd27ab7..eeb6799 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -139,6 +139,12 @@ public static class DialogShots
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
// 태그 제안이 타입별로 좁혀지는지 — 이미지는 서명·직인 6종만 떠야 한다
("12b-inspector-picture-data", () => HostInspector(designer, "MPictureBox1", InspectorTab.Data)),
+ // 정렬 바는 선택 상태에 따라 나타나야 한다 — 선택 0 과 페이지 속성 모드에서는 안 보여야 하는데
+ // 그건 수치로 못 잡는다(HasSelection 을 조건으로 쓰면 페이지 모드에서 뜬다).
+ // 15/16 이 같은 designer 에 GroupSelection 을 걸므로 반드시 그 앞에 둔다.
+ ("12c-inspector-none", () => HostInspectorSelection(designer, InspectorShotMode.None)),
+ ("12d-inspector-multi", () => HostInspectorSelection(designer, InspectorShotMode.Multi)),
+ ("12e-inspector-page", () => HostInspectorSelection(designer, InspectorShotMode.Page)),
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
designer.LayerTypeCandidates(), new[] { "Label" })),
// 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다.
@@ -244,6 +250,38 @@ public static class DialogShots
};
}
+ /// 정렬 바가 선택 상태에 따라 나타나고 사라지는지 — 그림으로만 확인되는 것들
+ private enum InspectorShotMode { None, Multi, Page }
+
+ private static Window HostInspectorSelection(DesignerViewModel designer, InspectorShotMode mode)
+ {
+ var page = designer.Pages[0];
+ switch (mode)
+ {
+ case InspectorShotMode.Multi:
+ designer.Selection.Set(page.Controls.Take(3).ToList(), page.Controls[0]);
+ break;
+ case InspectorShotMode.Page:
+ designer.Selection.Clear();
+ designer.ActivatePage(page);
+ break;
+ default:
+ designer.Selection.Clear();
+ break;
+ }
+ designer.Inspector.SelectedTab = InspectorTab.Design;
+ designer.Inspector.Rebuild();
+
+ return new Window
+ {
+ Title = $"속성 — {mode}",
+ Width = 300,
+ Height = 720,
+ Content = new Views.InspectorView { DataContext = designer.Inspector },
+ Background = Application.Current.TryFindResource("B.Panel") as Brush,
+ };
+ }
+
/// 화면 밖에 띄워 레이아웃을 돌린 뒤 PNG 로 찍고 닫는다
private static void Capture(Window window, string pngPath)
{
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 2321401..e1eb683 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -1093,6 +1093,127 @@ public static class EditSmoke
dirtyDoc.Undo.Undo();
Check("더티: 분기 후에는 저장 지점 소실로 계속 dirty", dirtyDoc.Undo.IsDirty);
+ // 24-1) 정렬 — 기준이 무엇이 되는가. 산술 자체는 단위 테스트(AlignSolverTests)가 보고,
+ // 여기서는 배선을 본다: 어느 사각형을 기준으로 잡았는가.
+ // 별도 문서에 붙인다 — 메인 designer 에 붙이면 아래 '전체 Undo' 의 guard(80)를 갉아먹는다.
+ {
+ var alignDoc = new DesignerViewModel(business.CreateNew());
+ ControlViewModel Put(double x, double y, double w, double h)
+ {
+ alignDoc.AddControlAt("Label", new Point(x, y));
+ var vm = alignDoc.Pages[0].Controls[^1];
+ vm.X = x;
+ vm.Y = y;
+ vm.Width = w;
+ vm.Height = h;
+ return vm;
+ }
+
+ var page0 = alignDoc.Pages[0];
+ var solo = Put(137, 211, 75, 20);
+
+ alignDoc.Selection.SetSingle(solo);
+ alignDoc.Align("Left");
+ Check("정렬: 1개는 페이지 기준 — 왼쪽", solo.X == 0, $"실제 {solo.X}");
+
+ alignDoc.Align("Right");
+ Check("정렬: 1개는 페이지 기준 — 오른쪽", solo.X == page0.WidthDip - solo.Width,
+ $"실제 {solo.X} 기대 {page0.WidthDip - solo.Width}");
+
+ alignDoc.Align("CenterV");
+ Check("정렬: 1개는 페이지 기준 — 세로 가운데", solo.Y > 0 && solo.Y == Math.Round((page0.HeightDip - solo.Height) / 2, MidpointRounding.AwayFromZero),
+ $"실제 {solo.Y}");
+
+ var beforeIdle = alignDoc.Undo.CanUndo;
+ var idleX = solo.X;
+ alignDoc.Align("Right");
+ Check("정렬: 이미 맞은 상태를 다시 눌러도 실행취소가 쌓이지 않는다",
+ solo.X == idleX && alignDoc.Undo.CanUndo == beforeIdle);
+
+ // 실행취소 1회로 완전 복원 — 정렬 1회 = 1스텝
+ var beforeUndoX = solo.X;
+ alignDoc.Align("Left");
+ alignDoc.Undo.Undo();
+ var restored = alignDoc.Pages[0].Controls.FirstOrDefault(c => c.Width == 75);
+ Check("정렬: 실행취소 1회로 되돌아온다", restored is not null && restored.X == beforeUndoX,
+ $"실제 {restored?.X}");
+
+ // 컨테이너 자식은 페이지가 아니라 부모가 기준 — 페이지 기준이면 X 가 음수가 된다
+ var nestDoc = new DesignerViewModel(business.CreateNew());
+ nestDoc.AddControlAt("Panel", new Point(120, 90));
+ var holder = nestDoc.Pages[0].Controls[^1] as ContainerViewModel;
+ if (holder is not null)
+ {
+ holder.X = 120;
+ holder.Y = 90;
+ holder.Width = 300;
+ holder.Height = 200;
+ nestDoc.Selection.SetSingle(holder);
+ nestDoc.AddControlAt("Label", new Point(180, 140));
+ var inner = holder.Children.Count > 0 ? holder.Children[^1] : null;
+ if (inner is not null)
+ {
+ inner.X = 60;
+ inner.Y = 50;
+ inner.Width = 80;
+ inner.Height = 20;
+
+ nestDoc.Selection.SetSingle(inner);
+ nestDoc.Align("Left");
+ Check("정렬: 컨테이너 자식은 부모 기준(음수로 튀지 않는다)", inner.X == 0, $"실제 {inner.X}");
+
+ nestDoc.Align("Right");
+ Check("정렬: 컨테이너 자식 오른쪽도 부모 기준",
+ inner.X == holder.Width - inner.Width, $"실제 {inner.X}");
+
+ // 부모와 자식을 함께 선택하면 자식은 대상에서 빠진다(두 번 옮겨지지 않게)
+ var innerBefore = inner.X;
+ nestDoc.Selection.Set(new List { holder, inner }, holder);
+ nestDoc.Align("Left");
+ Check("정렬: 조상이 선택돼 있으면 자식은 건드리지 않는다", inner.X == innerBefore,
+ $"실제 {inner.X} (시작 {innerBefore})");
+ }
+ }
+
+ // 2페이지 컨트롤 1개 — 그 페이지 사각형을 기준으로 잡아야 한다
+ var twoPage = new DesignerViewModel(business.CreateNew());
+ twoPage.AddControlAt("Label", new Point(80, 120));
+ twoPage.AddPage();
+ if (twoPage.Pages.Count >= 2)
+ {
+ var second = twoPage.Pages[1];
+ twoPage.AddControlAt("Label", new Point(200, second.OffsetY + 300));
+ var onSecond = second.Controls.Count > 0 ? second.Controls[^1] : null;
+ if (onSecond is not null)
+ {
+ onSecond.X = 200;
+ onSecond.Y = 300;
+ onSecond.Width = 60;
+ onSecond.Height = 20;
+ twoPage.Selection.SetSingle(onSecond);
+ twoPage.Align("Top");
+ Check("정렬: 2페이지 컨트롤은 그 페이지 기준", onSecond.Y == 0, $"실제 {onSecond.Y}");
+
+ // 페이지를 넘는 선택 — 세로는 거부, 가로는 허용
+ var onFirst = twoPage.Pages[0].Controls.Count > 0 ? twoPage.Pages[0].Controls[0] : null;
+ if (onFirst is not null)
+ {
+ var mixedY = onSecond.Y;
+ var canUndoBefore = twoPage.Undo.CanUndo;
+ twoPage.Selection.Set(new List { onFirst, onSecond }, onFirst);
+ twoPage.Align("Top");
+ Check("정렬: 페이지를 넘는 선택은 세로 정렬을 거부한다",
+ onSecond.Y == mixedY && twoPage.Undo.CanUndo == canUndoBefore,
+ $"실제 {onSecond.Y} (시작 {mixedY})");
+
+ twoPage.Align("Left");
+ Check("정렬: 페이지를 넘어도 가로는 맞는다", onFirst.X == onSecond.X,
+ $"실제 {onFirst.X} / {onSecond.X}");
+ }
+ }
+ }
+ }
+
// 24-2) 스냅 — 정렬 가이드와 간격 균등.
// 별도 문서에 붙인다. 메인 designer 에 붙이면 아래 '전체 Undo' 의 guard(80)를
// 드래그마다 잡아먹어 그 검사가 조용히 깨진다(UndoService 용량 100).
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index 25bedc9..cd65b76 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -1463,35 +1463,138 @@ public sealed class DesignerViewModel : ViewModelBase
Selection.Set(page.Controls.Where(c => !c.Model.Hidden), page.Controls.LastOrDefault());
}
- /// 선택 정렬 — 선택 bbox 기준(Left/Right/Top/Bottom/CenterH/CenterV)
+ ///
+ /// 선택 정렬 — 기준은 언제나 '담고 있는 것'이다.
+ ///
+ /// 선택이 2개 이상이면 선택 영역이 그 자리를 대신하고, 1개면 그 컨트롤을 담고 있는 것 —
+ /// 컨테이너 안이면 컨테이너, 최상위면 페이지 — 이 기준이 된다. 피그마와 같은 규칙이다.
+ ///
+ /// 컨테이너 자식을 페이지 기준으로 맞추지 않는 이유. X/Y 는 부모 상대좌표라
+ /// "페이지 왼쪽"에 맞추면 음수가 되어 부모 밖으로 나간다. 디자이너 캔버스는 클리핑을 안 해서
+ /// 멀쩡히 보이지만 레거시 런타임에서는 부모가 잘라먹는다 — 화면과 인쇄가 갈라지고 눈으로 못 잡는다.
+ ///
public void Align(string mode)
{
- if (Selection.Items.Count < 2)
+ if (Selection.Items.Count == 0 || SheetMe.Core.Layout.AlignSolver.Parse(mode) is not { } direction)
{
return;
}
- var bbox = SelectionWorldBounds();
- if (bbox is null)
+
+ // 부모와 그 자식을 함께 선택할 수 있다(레이어 목록). 부모를 옮기면 자식 월드 좌표가 따라 움직이는데
+ // 자식에게도 절대좌표를 대입하면 두 번 옮겨져 어긋난다 — 조상이 선택에 있는 것은 뺀다.
+ var targets = Selection.Items.Where(vm => !HasSelectedAncestor(vm)).ToList();
+ if (targets.Count == 0)
{
return;
}
- Undo.Snapshot();
- foreach (var vm in Selection.Items)
+
+ // 다른 종이의 '위 맞춤'에는 뜻이 없다. 예전에는 이 경우 컨트롤이 제 종이 위 허공으로 날아갔다
+ // (bbox 는 첫 페이지 월드 Y 인데 offset 은 자기 페이지 OffsetY 라 vm.Y 가 큰 음수가 됐다).
+ if (SheetMe.Core.Layout.AlignSolver.IsVertical(direction)
+ && targets.Select(PageOf).Distinct().Count() > 1)
+ {
+ return;
+ }
+
+ if (ReferenceFor(targets) is not { } reference)
+ {
+ return;
+ }
+
+ // 버튼으로 노출되면 사람은 연타한다. 무조건 스냅샷을 찍으면 빈 실행취소 단계가 쌓이고
+ // '저장 안 한 변경'으로 표시된다 — 실제로 움직일 때만 찍는다.
+ var moves = new List<(ControlViewModel Vm, double X, double Y)>(targets.Count);
+ var changed = false;
+ foreach (var vm in targets)
{
var offset = ParentWorldOffset(vm);
- switch (mode)
- {
- case "Left": vm.X = Math.Round(bbox.Value.Left - offset.X); break;
- case "Right": vm.X = Math.Round(bbox.Value.Right - vm.Width - offset.X); break;
- case "Top": vm.Y = Math.Round(bbox.Value.Top - offset.Y); break;
- case "Bottom": vm.Y = Math.Round(bbox.Value.Bottom - vm.Height - offset.Y); break;
- case "CenterH": vm.X = Math.Round(bbox.Value.Left + (bbox.Value.Width - vm.Width) / 2 - offset.X); break;
- case "CenterV": vm.Y = Math.Round(bbox.Value.Top + (bbox.Value.Height - vm.Height) / 2 - offset.Y); break;
- }
+ var world = new SheetMe.Core.Layout.SnapRect(
+ offset.X + vm.X, offset.Y + vm.Y, vm.Width, vm.Height);
+ var aligned = SheetMe.Core.Layout.AlignSolver.Apply(world, reference, direction);
+ var x = aligned.X - offset.X;
+ var y = aligned.Y - offset.Y;
+ changed |= x != vm.X || y != vm.Y;
+ moves.Add((vm, x, y));
}
+ if (!changed)
+ {
+ return;
+ }
+
+ Undo.Snapshot();
+ foreach (var (vm, x, y) in moves)
+ {
+ vm.X = x;
+ vm.Y = y;
+ }
+ // 페이지 소속은 바뀌지 않는다(세로 다중 페이지는 위에서 거부했다) — ReassignPagesAfterMove 는 부르지 않는다
Selection.NotifyBoundsChanged();
}
+ /// 정렬 기준 사각형(월드) — 여럿이면 선택 영역, 하나면 그것을 담고 있는 것
+ private SheetMe.Core.Layout.SnapRect? ReferenceFor(IReadOnlyList targets)
+ {
+ if (targets.Count >= 2)
+ {
+ if (SelectionWorldBounds() is not { } bbox)
+ {
+ return null;
+ }
+ return new SheetMe.Core.Layout.SnapRect(bbox.X, bbox.Y, bbox.Width, bbox.Height);
+ }
+
+ var only = targets[0];
+ if (only.Parent is { } parent)
+ {
+ var rect = WorldBoundsOf(parent);
+ return new SheetMe.Core.Layout.SnapRect(rect.X, rect.Y, rect.Width, rect.Height);
+ }
+ // 스냅(SnapEngine)이 쓰는 용지 사각형 표현과 글자 그대로 같아야 한다 —
+ // 갈라지면 가이드선이 가리키는 자리와 정렬 결과가 어긋난다
+ return PageOf(only) is { } page
+ ? new SheetMe.Core.Layout.SnapRect(0, page.OffsetY, page.WidthDip, page.HeightDip)
+ : null;
+ }
+
+ /// 부모 체인에 선택된 것이 있는가
+ private bool HasSelectedAncestor(ControlViewModel viewModel)
+ {
+ var parent = viewModel.Parent;
+ while (parent is not null)
+ {
+ if (Selection.Items.Contains(parent))
+ {
+ return true;
+ }
+ parent = parent.Parent;
+ }
+ return false;
+ }
+
+ /// 정렬 기준을 사람 말로 — 인스펙터 툴팁이 이걸 물고 있다
+ public string AlignScopeText()
+ {
+ var items = Selection.Items;
+ if (items.Count == 0)
+ {
+ return string.Empty;
+ }
+ if (items.Count >= 2)
+ {
+ return items.Select(PageOf).Distinct().Count() > 1
+ ? "선택 영역 기준 (여러 페이지)"
+ : "선택 영역 기준";
+ }
+ var only = items[0];
+ return only.Parent is { } parent
+ ? $"{(parent.Id.Length > 0 ? parent.Id : "부모")} 기준"
+ : "페이지 기준";
+ }
+
+ /// 세로 정렬이 가능한가 — 여러 페이지에 걸친 선택에서는 뜻이 없다
+ public bool CanAlignVertical()
+ => Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
+
///
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
index 1f89201..621e857 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
@@ -45,6 +45,31 @@ public sealed class InspectorViewModel : ViewModelBase
///
public bool HasSelection => designer.Selection.Items.Count > 0 || designer.InspectedPage is not null;
+ ///
+ /// 정렬 바를 보일 것인가.
+ ///
+ /// 을 쓰면 안 된다 — 그건 페이지 속성 모드에서도 true 라
+ /// 정렬할 컨트롤이 없는데 바가 뜬다. 선택 0 에서 접히지 않으면 '컨트롤을 선택하면…' 안내문
+ /// 뒤에 버튼 줄이 그대로 남는다(안내문과 같은 Grid 에 겹쳐 그려진다).
+ ///
+ public bool ShowAlignBar => designer.Selection.Items.Count > 0 && selectedTab == InspectorTab.Design;
+
+ /// 세로 정렬 가능 — 여러 페이지에 걸친 선택에서는 뜻이 없다
+ public bool CanAlignVertical => designer.CanAlignVertical();
+
+ /// 지금 무엇을 기준으로 정렬하는가 — 버튼 툴팁이 이걸 물고 있다
+ public string AlignScopeText => designer.AlignScopeText();
+
+ /// 세로 버튼 툴팁 — 못 쓰는 상태면 그 이유를 말한다
+ public string VerticalScopeText => CanAlignVertical
+ ? AlignScopeText
+ : "여러 페이지에 걸친 선택은 세로 정렬을 할 수 없습니다";
+
+ ///
+ /// 정렬 실행 — 뷰의 DataContext 가 인스펙터라 디자이너 커맨드에 직접 닿을 수 없어 위임한다.
+ ///
+ public M.Framework.WPF.ICustomCommand AlignCommand { get; }
+
///
/// 선택된 탭. 컨트롤을 바꿔도 유지된다 — 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는
/// 작업이 흔한데, 선택할 때마다 디자인 탭으로 되돌아가면 매번 다시 눌러야 한다.
@@ -146,6 +171,8 @@ public sealed class InspectorViewModel : ViewModelBase
public InspectorViewModel(DesignerViewModel designer)
{
this.designer = designer;
+ AlignCommand = new M.Framework.WPF.Command(
+ (object parameter) => designer.Align(parameter as string ?? string.Empty));
designer.Selection.SetChanged += Rebuild;
designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신
}
@@ -175,6 +202,10 @@ public sealed class InspectorViewModel : ViewModelBase
currentSection = null;
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(HasSelection));
+ OnPropertyChanged(nameof(ShowAlignBar));
+ OnPropertyChanged(nameof(CanAlignVertical));
+ OnPropertyChanged(nameof(AlignScopeText));
+ OnPropertyChanged(nameof(VerticalScopeText));
var items = designer.Selection.Items;
if (items.Count == 0)
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index 6724756..fc03c07 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -98,6 +98,40 @@
+
+
+