diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 596c0a4..16fca95 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -479,6 +479,62 @@ public static class EditSmoke badgeTarget.Model.Props.SetText("DataTableField", string.Empty); } + // 20-2-1-2) 페이지 속성 — 레거시 MDesignerHost 루트가 갖는 속성을 편집한다 + { + var pg = new DesignerViewModel(business.CreateNew()); + pg.AddControlAt("Label", new Point(100, 100)); + var pageRow = pg.LayerRows.OfType().First(); + + pg.ActivatePage(pageRow.Page); + Check("페이지 속성: 페이지 모드 진입", pg.InspectedPage is not null && pg.Selection.Items.Count == 0); + Check("페이지 속성: 요약이 페이지를 가리킴", pg.Inspector.Summary.StartsWith("페이지 1"), + $"실제 {pg.Inspector.Summary}"); + Check("페이지 속성: 행 생성", pg.Inspector.Rows.Count > 0, $"행 {pg.Inspector.Rows.Count}"); + + // 용지 크기 — raw Size 가 아니라 Bounds 를 고쳐야 저장 시 되돌아가지 않는다 + var sizePair = pg.Inspector.Rows.OfType().First(); + ((ViewModels.Inspector.NumberRowViewModel)sizePair.Left).ValueText = "794"; + ((ViewModels.Inspector.NumberRowViewModel)sizePair.Right).ValueText = "1123"; + Check("페이지 속성: 용지 크기 편집(A4)", + pg.Pages[0].WidthDip == 794 && pg.Pages[0].HeightDip == 1123, + $"실제 {pg.Pages[0].WidthDip}×{pg.Pages[0].HeightDip}"); + + // 저장하면 writer 가 Bounds 로 Size 를 다시 쓴다 — 그 값이 편집 결과와 같아야 한다 + var pageXml = serializer.Write(pg.Document); + Check("페이지 속성: 저장 XML 의 Size 가 편집값", pageXml.Contains("794, 1123"), + "Size 가 Bounds 와 어긋남"); + + // 배경색 + var colorRow = pg.Inspector.Rows.OfType() + .FirstOrDefault(r => r.Label == "배경색"); + Check("페이지 속성: 배경색 행 존재", colorRow is not null); + if (colorRow is not null) + { + colorRow.ValueText = "Silver"; + Check("페이지 속성: 배경색 편집 → 루트 반영", + pg.Pages[0].Model.Root.Props.GetText("BackColor") == "Silver"); + } + + // 기본 글꼴 — 명시 Font 가 없는 컨트롤이 상속한다 + var fontRow = pg.Inspector.Rows.OfType() + .FirstOrDefault(r => r.Label == "글꼴"); + Check("페이지 속성: 기본 글꼴 행 존재", fontRow is not null); + if (fontRow is not null) + { + fontRow.ValueText = "돋움"; + Check("페이지 속성: 기본 글꼴이 루트에 기록", + (pg.Pages[0].Model.Root.Props.GetText("Font") ?? string.Empty).StartsWith("돋움"), + $"실제 {pg.Pages[0].Model.Root.Props.GetText("Font")}"); + } + + Check("페이지 속성: 편집이 되돌리기 스텝", pg.Undo.CanUndo); + + // 컨트롤을 고르면 페이지 모드에서 빠진다 + pg.Selection.SetSingle(pg.Pages[0].Controls[0]); + Check("페이지 속성: 컨트롤 선택 시 해제", + pg.InspectedPage is null && !pg.Inspector.Summary.StartsWith("페이지")); + } + // 20-2-2) 알림이 무인 실행을 멈추지 않는다 — 진단 모드에서는 창을 만들지 않고 즉시 // 기각 기본값을 돌려줘야 한다. 이 규약이 깨지면 여기서 영원히 대기한다. Check("알림: 진단 모드에서 창 없이 반환", Services.UserSession.IsDiagnostic); diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index 267d5da..c4156c1 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -77,6 +77,7 @@ public sealed class DesignerViewModel : ViewModelBase // 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님) private readonly HashSet collapsedGroups = new(StringComparer.Ordinal); private readonly HashSet collapsedPages = new(); + private PageViewModel? inspectedPage; private readonly HashSet layerTypeFilter = new(StringComparer.Ordinal); private readonly List watchedControls = new(); #endregion @@ -275,6 +276,9 @@ public sealed class DesignerViewModel : ViewModelBase Interaction = new InteractionController(this); Inspector = new InspectorViewModel(this); Selection.Changed += RefreshOverlay; + // 순서 주의 — 인스펙터가 Rebuild 하기 전에 페이지 모드를 걷어야 한다 + // (Inspector 는 생성자 안에서 SetChanged 를 구독하므로 여기가 먼저다) + Selection.SetChanged += ClearInspectedPageOnSelection; Selection.SetChanged += LayerSelectionChanged; UndoCommand = new Command((sender, e) => Undo.Undo()); @@ -502,6 +506,33 @@ public sealed class DesignerViewModel : ViewModelBase OnPropertyChanged(nameof(WorldHeight)); } + /// + /// 용지 크기가 바뀐 뒤 — 페이지 스택 오프셋과 월드 크기를 다시 계산한다. + /// 페이지가 여러 장이면 한 장의 높이 변화가 아래 페이지 전부를 밀어낸다. + /// + public void NotifyPaperResized() + { + RecalcPageOffsets(); + OnPropertyChanged(nameof(WorldWidth)); + OnPropertyChanged(nameof(WorldHeight)); + RefreshOverlay(); + } + + /// + /// 페이지 기본 글꼴이 바뀐 뒤 — 그 페이지 컨트롤의 상속 컨텍스트를 다시 흘린다. + /// Font 를 명시하지 않은 컨트롤은 루트 글꼴을 물려받으므로 값 하나가 화면 전체를 바꾼다. + /// + public void RefreshPageInheritance(PageViewModel page) + { + var rootFontText = page.Model.Root.Props.GetText("Font"); + var rootFont = rootFontText is not null ? LegacyFormat.ParseFont(rootFontText) : new LegacyFont(); + foreach (var control in page.Controls) + { + control.ResolveVisualContext(rootFont, control.ParentForeground); + } + RefreshOverlay(); + } + /// 문서 교체 후 재구성 — Undo 복원 경로 public void ReplaceDocument(FormDocument newDocument) { @@ -1773,13 +1804,20 @@ public sealed class DesignerViewModel : ViewModelBase } } - /// 페이지 헤더 행 클릭 — 활성 페이지로 삼고 캔버스를 그 페이지로 옮긴다 + /// + /// 페이지 헤더 행 클릭 — 활성 페이지로 삼고, 캔버스를 그 페이지로 옮기고, 속성 패널을 페이지로 돌린다. + /// + /// 페이지도 레거시에서는 하나의 Object(MDesignerHost)라 자기 속성을 갖는다. + /// 컨트롤을 하나도 고르지 않은 상태에서만 페이지 속성을 보여주므로 선택은 비운다. + /// public void ActivatePage(PageViewModel page) { if (!ReferenceEquals(SelectedPage, page)) { SelectedPage = page; } + Selection.Clear(); + InspectedPage = page; foreach (var header in LayerRows.OfType()) { header.NotifyStateChanged(); @@ -1787,6 +1825,32 @@ public sealed class DesignerViewModel : ViewModelBase ScrollToPageRequested?.Invoke(page); } + /// + /// 속성 패널이 들여다보는 페이지 — 컨트롤 선택이 없을 때만 유효하다. + /// 컨트롤을 고르는 순간 경로에서 비워진다. + /// + public PageViewModel? InspectedPage + { + get => inspectedPage; + private set + { + if (!ReferenceEquals(inspectedPage, value)) + { + inspectedPage = value; + Inspector.Rebuild(); + } + } + } + + /// 선택이 생기면 페이지 속성 모드를 벗어난다 + private void ClearInspectedPageOnSelection() + { + if (inspectedPage is not null && Selection.Items.Count > 0) + { + inspectedPage = null; + } + } + /// 캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다 public event Action? ScrollToPageRequested; diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs index a0281a4..760dfb2 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -120,12 +120,21 @@ public sealed class InspectorViewModel : ViewModelBase /// 선택 요약 텍스트 public string Summary - => designer.Selection.Items.Count switch + { + get { - 0 => "선택 없음", - 1 => $"{designer.Selection.Primary!.Type} — {designer.Selection.Primary!.Id}", - var n => $"{n}개 선택", - }; + if (designer.Selection.Items.Count == 0 && designer.InspectedPage is { } page) + { + return $"페이지 {page.Index + 1} — {page.Model.Root.Id}"; + } + return designer.Selection.Items.Count switch + { + 0 => "선택 없음", + 1 => $"{designer.Selection.Primary!.Type} — {designer.Selection.Primary!.Id}", + var n => $"{n}개 선택", + }; + } + } #endregion #region Constructors @@ -166,6 +175,11 @@ public sealed class InspectorViewModel : ViewModelBase if (items.Count == 0) { RefreshTabCounts(null); + if (designer.InspectedPage is { } page) + { + BuildPageRows(page); + OnPropertyChanged(nameof(IsTabEmpty)); + } return; } @@ -265,6 +279,148 @@ public sealed class InspectorViewModel : ViewModelBase OnPropertyChanged(nameof(ShowTabStrip)); } + /// + /// 페이지 속성 — 레거시에서 페이지는 MDesignerHost 라는 하나의 Object 이고 자기 속성을 갖는다. + /// SheetMe 는 그 루트 노드를 FormPage.Root 로 통째로 보존해 왔지만(그래서 왕복 diff 가 0이다) + /// 편집 화면이 없어 용지 크기조차 바꿀 수 없었다. + /// + /// 저장 시 Size·Name 은 writer 가 Bounds·Id 에서 다시 만들어 덮으므로 + /// 그 두 키는 raw 로 편집하게 두면 안 된다 — 고쳐도 저장하면 되돌아간다. + /// + private void BuildPageRows(PageViewModel page) + { + var root = page.Model.Root; + + AddSection("용지"); + // 크기는 raw Size 가 아니라 Bounds 를 고친다 — writer 가 Bounds 로 Size 를 다시 쓰기 때문 + AddPagePair(page, + "너비", () => page.Model.Width, w => page.Model.Width = Math.Max(1, Math.Round(w)), + "높이", () => page.Model.Height, h => page.Model.Height = Math.Max(1, Math.Round(h))); + + AddPageRow(page, new ColorRowViewModel("배경색"), + () => root.Props.GetText("BackColor") ?? "White", + value => root.Props.SetText("BackColor", value.Length == 0 ? "White" : value), + page.NotifyPaperChanged); + + // 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다 + AddSection("기본 글꼴 (컨트롤이 상속)"); + AddPageFontRow(page, "글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v); + AddPageFontRow(page, "크기(pt)", + f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture), + (f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 + ? size + : f.SizePt); + + // 저장 시 writer 가 다시 쓰는 키는 raw 편집 대상에서 제외한다 + var handled = new HashSet(StringComparer.Ordinal) { "Size", "Name", "BackColor", "Font" }; + BuildPageAdvancedRows(page, handled); + } + + /// 페이지 루트의 나머지 원본 속성 — 레거시 PropertyGrid 등가의 탈출구 + private void BuildPageAdvancedRows(PageViewModel page, HashSet handled) + { + var root = page.Model.Root; + var keys = root.Props.Keys.Where(k => !handled.Contains(k)).ToList(); + + if (!showAdvanced) + { + currentSection = null; + AddPlain(new ToggleAdvancedRowViewModel($"전체 속성 표시 ▾ ({keys.Count}개)", () => + { + showAdvanced = true; + Rebuild(); + })); + return; + } + + AddSection("전체 속성 (고급 — 레거시 원문)"); + AddPlain(new ToggleAdvancedRowViewModel("전체 속성 숨기기 ▴", () => + { + showAdvanced = false; + Rebuild(); + })); + + foreach (var key in keys) + { + // 페이지 루트에 목록·중첩·바이너리 속성이 오는 사례는 없지만, 왔을 때 편집으로 + // 원문을 깨뜨리지 않도록 문자열만 편집을 허용한다 + if (root.Props.Get(key) is LegacyPropValue.TextValue or LegacyPropValue.NullValue or null) + { + AddPageRow(page, new TextRowViewModel(key), + () => root.Props.GetText(key) ?? string.Empty, + value => root.Props.SetText(key, value)); + continue; + } + AddPlain(new ReadOnlyRowViewModel(key, "(원문 보존됨)")); + } + } + + /// 페이지 속성 행 배선 — 대상이 선택 집합이 아니라 페이지 루트 하나다 + private void AddPageRow(PageViewModel page, PropertyRowViewModel row, + Func get, Action set, Action? afterCommit = null) + { + row.Initialize(get(), isMixed: false); + row.Commit = value => + { + designer.Undo.Snapshot(); + set(value); + afterCommit?.Invoke(); + }; + currentSection?.Adopt(row); + Rows.Add(row); + } + + /// 용지 너비·높이 — 한 줄에 둘. 값이 바뀌면 페이지 오프셋까지 다시 계산해야 한다 + private void AddPagePair(PageViewModel page, + string leftLabel, Func getLeft, Action setLeft, + string rightLabel, Func getRight, Action setRight) + { + var pair = new PairRowViewModel( + leftLabel, NewPageNumber(page, leftLabel, getLeft, setLeft), + rightLabel, NewPageNumber(page, rightLabel, getRight, setRight)); + currentSection?.Adopt(pair); + Rows.Add(pair); + } + + private NumberRowViewModel NewPageNumber(PageViewModel page, string label, + Func get, Action set) + { + var row = new NumberRowViewModel(label) { CoalesceUndo = true }; + row.Initialize(get().ToString("0.##", CultureInfo.InvariantCulture), isMixed: false); + row.Commit = value => + { + if (!double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)) + { + return; + } + designer.Undo.SnapshotForNudge(); + set(parsed); + page.Model.Root.HasSize = true; + page.NotifySizeChanged(); + designer.NotifyPaperResized(); + }; + return row; + } + + /// 페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다 + private void AddPageFontRow(PageViewModel page, string label, + Func get, Action set) + { + var root = page.Model.Root; + LegacyFont Current() + { + var text = root.Props.GetText("Font"); + return text is not null ? LegacyFormat.ParseFont(text) : new LegacyFont(); + } + + AddPageRow(page, new TextRowViewModel(label), () => get(Current()), value => + { + var font = Current(); + set(font, value); + root.Props.SetText("Font", LegacyFormat.FormatFont(font)); + }, () => designer.RefreshPageInheritance(page)); + } + /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가 private void BuildAdvancedRows(ControlViewModel target, HashSet curatedKeys) { diff --git a/src/SheetMe.Designer/ViewModels/PageViewModel.cs b/src/SheetMe.Designer/ViewModels/PageViewModel.cs index 07f88f2..df940f8 100644 --- a/src/SheetMe.Designer/ViewModels/PageViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/PageViewModel.cs @@ -70,6 +70,9 @@ public sealed class PageViewModel : ViewModelBase OnPropertyChanged(nameof(SizeText)); } + /// 용지 배경 변경 통지 — 루트 BackColor 편집 후 + public void NotifyPaperChanged() => OnPropertyChanged(nameof(PaperBrush)); + /// 페이지 패널 표시 텍스트 public string SizeText => $"{WidthDip:0}×{HeightDip:0}"; #endregion