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 @@ + + +