페이지 속성 편집 — 레이어의 페이지 행을 고르면 속성 패널이 페이지를 연다

레거시에서 페이지는 MDesignerHost 라는 하나의 Object 이고 자기 속성을 갖는다.
SheetMe 도 그 루트 노드를 FormPage.Root 로 통째로 보존해 왔고(그래서 운영 1,271건 왕복
diff 가 0이다), 값도 일부는 이미 쓰고 있었다 — BackColor 는 용지 배경으로 렌더하고
Font 는 자식이 상속한다. 없던 것은 편집 화면뿐이었다.

그 결과 용지 크기조차 바꿀 수 없는 상태였다. ApplyPaperSize 는 구현돼 있었지만 호출처가
편집 스모크뿐이고 메뉴·XAML 어디에도 배선이 없었다 — 새 서식은 720×856 고정으로만 시작했다.

레이어 아웃라인의 페이지 헤더 행을 누르면 선택을 비우고 속성 패널이 페이지 모드로 바뀐다.
컨트롤을 하나라도 고르면 자동으로 빠져나온다.

  용지        너비·높이(한 줄), 배경색
  기본 글꼴    글꼴·크기 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다
  전체 속성    나머지 원본 속성(레거시 PropertyGrid 등가)

저장 규약을 지켰다. writer 는 저장할 때 Size 를 Bounds 에서, Name 을 Id 에서 다시 만들어
덮으므로 그 두 키는 raw 편집 대상에서 뺐다 — 열어 두면 고쳐도 저장하면 되돌아간다.
크기는 raw Size 가 아니라 Bounds 를 고치고, 편집 결과가 저장 XML 의 Size 와 같은지
스모크로 못 박았다.

용지 크기가 바뀌면 페이지 스택 오프셋과 월드 크기를 다시 계산한다(여러 장이면 한 장의
높이 변화가 아래 페이지를 전부 밀어낸다). 기본 글꼴이 바뀌면 그 페이지 컨트롤의 상속
컨텍스트를 다시 흘린다.

편집 스모크에 페이지 속성 검사 11건 추가.
회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 10:23:49 +09:00
co-authored by Claude Opus 5
parent f5cce00019
commit 38093691ad
4 changed files with 285 additions and 6 deletions
@@ -479,6 +479,62 @@ public static class EditSmoke
badgeTarget.Model.Props.SetText("DataTableField", string.Empty); 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<LayerPageViewModel>().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<ViewModels.Inspector.PairRowViewModel>().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<ViewModels.Inspector.ColorRowViewModel>()
.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<ViewModels.Inspector.TextRowViewModel>()
.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) 알림이 무인 실행을 멈추지 않는다 — 진단 모드에서는 창을 만들지 않고 즉시 // 20-2-2) 알림이 무인 실행을 멈추지 않는다 — 진단 모드에서는 창을 만들지 않고 즉시
// 기각 기본값을 돌려줘야 한다. 이 규약이 깨지면 여기서 영원히 대기한다. // 기각 기본값을 돌려줘야 한다. 이 규약이 깨지면 여기서 영원히 대기한다.
Check("알림: 진단 모드에서 창 없이 반환", Services.UserSession.IsDiagnostic); Check("알림: 진단 모드에서 창 없이 반환", Services.UserSession.IsDiagnostic);
@@ -77,6 +77,7 @@ public sealed class DesignerViewModel : ViewModelBase
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님) // 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal); private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
private readonly HashSet<PageViewModel> collapsedPages = new(); private readonly HashSet<PageViewModel> collapsedPages = new();
private PageViewModel? inspectedPage;
private readonly HashSet<string> layerTypeFilter = new(StringComparer.Ordinal); private readonly HashSet<string> layerTypeFilter = new(StringComparer.Ordinal);
private readonly List<System.Collections.Specialized.INotifyCollectionChanged> watchedControls = new(); private readonly List<System.Collections.Specialized.INotifyCollectionChanged> watchedControls = new();
#endregion #endregion
@@ -275,6 +276,9 @@ public sealed class DesignerViewModel : ViewModelBase
Interaction = new InteractionController(this); Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this); Inspector = new InspectorViewModel(this);
Selection.Changed += RefreshOverlay; Selection.Changed += RefreshOverlay;
// 순서 주의 — 인스펙터가 Rebuild 하기 전에 페이지 모드를 걷어야 한다
// (Inspector 는 생성자 안에서 SetChanged 를 구독하므로 여기가 먼저다)
Selection.SetChanged += ClearInspectedPageOnSelection;
Selection.SetChanged += LayerSelectionChanged; Selection.SetChanged += LayerSelectionChanged;
UndoCommand = new Command((sender, e) => Undo.Undo()); UndoCommand = new Command((sender, e) => Undo.Undo());
@@ -502,6 +506,33 @@ public sealed class DesignerViewModel : ViewModelBase
OnPropertyChanged(nameof(WorldHeight)); OnPropertyChanged(nameof(WorldHeight));
} }
/// <summary>
/// 용지 크기가 바뀐 뒤 — 페이지 스택 오프셋과 월드 크기를 다시 계산한다.
/// 페이지가 여러 장이면 한 장의 높이 변화가 아래 페이지 전부를 밀어낸다.
/// </summary>
public void NotifyPaperResized()
{
RecalcPageOffsets();
OnPropertyChanged(nameof(WorldWidth));
OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay();
}
/// <summary>
/// 페이지 기본 글꼴이 바뀐 뒤 — 그 페이지 컨트롤의 상속 컨텍스트를 다시 흘린다.
/// Font 를 명시하지 않은 컨트롤은 루트 글꼴을 물려받으므로 값 하나가 화면 전체를 바꾼다.
/// </summary>
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();
}
/// <summary>문서 교체 후 재구성 — Undo 복원 경로</summary> /// <summary>문서 교체 후 재구성 — Undo 복원 경로</summary>
public void ReplaceDocument(FormDocument newDocument) public void ReplaceDocument(FormDocument newDocument)
{ {
@@ -1773,13 +1804,20 @@ public sealed class DesignerViewModel : ViewModelBase
} }
} }
/// <summary>페이지 헤더 행 클릭 — 활성 페이지로 삼고 캔버스를 그 페이지로 옮긴다</summary> /// <summary>
/// 페이지 헤더 행 클릭 — 활성 페이지로 삼고, 캔버스를 그 페이지로 옮기고, 속성 패널을 페이지로 돌린다.
///
/// 페이지도 레거시에서는 하나의 <c>Object</c>(MDesignerHost)라 자기 속성을 갖는다.
/// 컨트롤을 하나도 고르지 않은 상태에서만 페이지 속성을 보여주므로 선택은 비운다.
/// </summary>
public void ActivatePage(PageViewModel page) public void ActivatePage(PageViewModel page)
{ {
if (!ReferenceEquals(SelectedPage, page)) if (!ReferenceEquals(SelectedPage, page))
{ {
SelectedPage = page; SelectedPage = page;
} }
Selection.Clear();
InspectedPage = page;
foreach (var header in LayerRows.OfType<LayerPageViewModel>()) foreach (var header in LayerRows.OfType<LayerPageViewModel>())
{ {
header.NotifyStateChanged(); header.NotifyStateChanged();
@@ -1787,6 +1825,32 @@ public sealed class DesignerViewModel : ViewModelBase
ScrollToPageRequested?.Invoke(page); ScrollToPageRequested?.Invoke(page);
} }
/// <summary>
/// 속성 패널이 들여다보는 페이지 — 컨트롤 선택이 없을 때만 유효하다.
/// 컨트롤을 고르는 순간 <see cref="SelectionService.SetChanged"/> 경로에서 비워진다.
/// </summary>
public PageViewModel? InspectedPage
{
get => inspectedPage;
private set
{
if (!ReferenceEquals(inspectedPage, value))
{
inspectedPage = value;
Inspector.Rebuild();
}
}
}
/// <summary>선택이 생기면 페이지 속성 모드를 벗어난다</summary>
private void ClearInspectedPageOnSelection()
{
if (inspectedPage is not null && Selection.Items.Count > 0)
{
inspectedPage = null;
}
}
/// <summary>캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다</summary> /// <summary>캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다</summary>
public event Action<PageViewModel>? ScrollToPageRequested; public event Action<PageViewModel>? ScrollToPageRequested;
@@ -120,12 +120,21 @@ public sealed class InspectorViewModel : ViewModelBase
/// <summary>선택 요약 텍스트</summary> /// <summary>선택 요약 텍스트</summary>
public string Summary public string Summary
=> designer.Selection.Items.Count switch {
get
{ {
0 => "선택 없음", if (designer.Selection.Items.Count == 0 && designer.InspectedPage is { } page)
1 => $"{designer.Selection.Primary!.Type} — {designer.Selection.Primary!.Id}", {
var n => $"{n}개 선택", 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 #endregion
#region Constructors #region Constructors
@@ -166,6 +175,11 @@ public sealed class InspectorViewModel : ViewModelBase
if (items.Count == 0) if (items.Count == 0)
{ {
RefreshTabCounts(null); RefreshTabCounts(null);
if (designer.InspectedPage is { } page)
{
BuildPageRows(page);
OnPropertyChanged(nameof(IsTabEmpty));
}
return; return;
} }
@@ -265,6 +279,148 @@ public sealed class InspectorViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowTabStrip)); OnPropertyChanged(nameof(ShowTabStrip));
} }
/// <summary>
/// 페이지 속성 — 레거시에서 페이지는 <c>MDesignerHost</c> 라는 하나의 Object 이고 자기 속성을 갖는다.
/// SheetMe 는 그 루트 노드를 <c>FormPage.Root</c> 로 통째로 보존해 왔지만(그래서 왕복 diff 가 0이다)
/// 편집 화면이 없어 용지 크기조차 바꿀 수 없었다.
///
/// 저장 시 <c>Size</c>·<c>Name</c> 은 writer 가 Bounds·Id 에서 다시 만들어 덮으므로
/// 그 두 키는 raw 로 편집하게 두면 안 된다 — 고쳐도 저장하면 되돌아간다.
/// </summary>
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<string>(StringComparer.Ordinal) { "Size", "Name", "BackColor", "Font" };
BuildPageAdvancedRows(page, handled);
}
/// <summary>페이지 루트의 나머지 원본 속성 — 레거시 PropertyGrid 등가의 탈출구</summary>
private void BuildPageAdvancedRows(PageViewModel page, HashSet<string> 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, "(원문 보존됨)"));
}
}
/// <summary>페이지 속성 행 배선 — 대상이 선택 집합이 아니라 페이지 루트 하나다</summary>
private void AddPageRow(PageViewModel page, PropertyRowViewModel row,
Func<string> get, Action<string> 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);
}
/// <summary>용지 너비·높이 — 한 줄에 둘. 값이 바뀌면 페이지 오프셋까지 다시 계산해야 한다</summary>
private void AddPagePair(PageViewModel page,
string leftLabel, Func<double> getLeft, Action<double> setLeft,
string rightLabel, Func<double> getRight, Action<double> 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<double> get, Action<double> 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;
}
/// <summary>페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다</summary>
private void AddPageFontRow(PageViewModel page, string label,
Func<LegacyFont, string> get, Action<LegacyFont, string> 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));
}
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary> /// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys) private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys)
{ {
@@ -70,6 +70,9 @@ public sealed class PageViewModel : ViewModelBase
OnPropertyChanged(nameof(SizeText)); OnPropertyChanged(nameof(SizeText));
} }
/// <summary>용지 배경 변경 통지 — 루트 BackColor 편집 후</summary>
public void NotifyPaperChanged() => OnPropertyChanged(nameof(PaperBrush));
/// <summary>페이지 패널 표시 텍스트</summary> /// <summary>페이지 패널 표시 텍스트</summary>
public string SizeText => $"{WidthDip:0}×{HeightDip:0}"; public string SizeText => $"{WidthDip:0}×{HeightDip:0}";
#endregion #endregion