using System.Collections.ObjectModel; using System.Globalization; using SheetMe.Core.Catalog; using SheetMe.Core.Models; using SheetMe.Core.Serialization; using SheetMe.Designer.Services; using SheetMe.Designer.ViewModels.Controls; namespace SheetMe.Designer.ViewModels.Inspector; /// /// 속성 인스펙터 — ControlRegistry 스키마 기반 행 구성. /// 선택 '집합' 변경 시에만 재구성(드래그 프레임 제외), 다중선택은 값 동일성 병합("여러 값"). /// 커밋 규약: 값 변경 시 Undo 스냅샷 1회 → 전체 선택 대상 적용 → 시각 재해석. /// public sealed class InspectorViewModel : ViewModelBase { #region Member Fields private const string StringItemAqn = "System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"; private readonly DesignerViewModel designer; private readonly List<(PropertyRowViewModel Row, RowBinding Binding)> boundsRows = new(); private bool showAdvanced; /// 공통/글꼴 섹션이 이미 다루는 키 — 고급 목록에서 제외 private static readonly HashSet HandledKeys = new(StringComparer.Ordinal) { "Location", "Size", "LocationOnBase", "Name", "Font", }; #endregion #region Properties /// 행 목록(섹션 헤더 포함) public ObservableCollection Rows { get; } = new(); /// 선택 요약 텍스트 public string Summary => designer.Selection.Items.Count switch { 0 => "선택 없음", 1 => $"{designer.Selection.Primary!.Type} — {designer.Selection.Primary!.Id}", var n => $"{n}개 선택", }; #endregion #region Constructors public InspectorViewModel(DesignerViewModel designer) { this.designer = designer; designer.Selection.SetChanged += Rebuild; designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신 } #endregion #region Methods /// 경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게) private void RefreshBoundsRows() { var items = designer.Selection.Items; if (items.Count == 0) { return; } foreach (var (row, binding) in boundsRows) { var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList(); row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1); } } /// 행 재구성 — 선택 집합 변경 시 public void Rebuild() { Rows.Clear(); boundsRows.Clear(); OnPropertyChanged(nameof(Summary)); var items = designer.Selection.Items; if (items.Count == 0) { return; } // 공통: 이름/위치/크기 Rows.Add(new SectionRowViewModel("공통")); if (items.Count == 1) { AddRow(new TextRowViewModel("이름"), new RowBinding { Get = vm => vm.Id, Set = (vm, value) => RenameControl(vm, value), AffectsVisual = false, }); } AddBoundsRow("X", vm => vm.X, (vm, v) => vm.X = v); AddBoundsRow("Y", vm => vm.Y, (vm, v) => vm.Y = v); AddBoundsRow("너비", vm => vm.Width, (vm, v) => vm.Width = Math.Max(1, v)); AddBoundsRow("높이", vm => vm.Height, (vm, v) => vm.Height = Math.Max(1, v)); // 폰트(공통) — Font Property(상속 시 빈 값) Rows.Add(new SectionRowViewModel("글꼴")); AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v); AddFontRow("크기(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); AddFontToggleRow("굵게", f => f.Bold, (f, v) => f.Bold = v); AddFontToggleRow("밑줄", f => f.Underline, (f, v) => f.Underline = v); // 타입 전용 — 전체 선택이 같은 타입일 때만 var type = items[0].Type; var curatedKeys = new HashSet(HandledKeys, StringComparer.Ordinal); if (items.All(i => i.Type == type) && ControlRegistry.Find(type) is { } descriptor && descriptor.Properties.Count > 0) { Rows.Add(new SectionRowViewModel(descriptor.DisplayName)); foreach (var def in descriptor.Properties) { AddDefRow(def); curatedKeys.Add(def.Key); } } // 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가) if (items.Count == 1) { BuildAdvancedRows(items[0], curatedKeys); } } /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가 private void BuildAdvancedRows(ControlViewModel target, HashSet curatedKeys) { var advancedKeys = target.Model.Props.Keys .Where(k => !curatedKeys.Contains(k)) .ToList(); if (!showAdvanced) { Rows.Add(new ToggleAdvancedRowViewModel($"전체 속성 표시 ▾ ({advancedKeys.Count}개)", () => { showAdvanced = true; Rebuild(); })); return; } Rows.Add(new SectionRowViewModel("전체 속성 (고급 — 레거시 원문)")); Rows.Add(new ToggleAdvancedRowViewModel("전체 속성 숨기기 ▴", () => { showAdvanced = false; Rebuild(); })); foreach (var key in advancedKeys) { var value = target.Model.Props.Get(key); switch (value) { case LegacyPropValue.TextValue or LegacyPropValue.NullValue or null: AddRow(new TextRowViewModel(key), new RowBinding { Get = vm => vm.Model.Props.GetText(key) ?? string.Empty, Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue), }); break; case LegacyPropValue.ItemsValue items: AddRow(new MultilineTextRowViewModel(key + " (목록)"), new RowBinding { Get = vm => vm.Model.Props.Get(key) is LegacyPropValue.ItemsValue iv ? string.Join("\n", iv.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty)) : string.Empty, Set = (vm, newValue) => { var itemsValue = new LegacyPropValue.ItemsValue(); var aqn = items.Items.FirstOrDefault()?.Aqn ?? StringItemAqn; foreach (var line in newValue.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { itemsValue.Items.Add(new LegacyItem { Aqn = aqn, Value = new LegacyPropValue.TextValue(line) }); } vm.Model.Props.Set(key, itemsValue); }, }); break; case LegacyPropValue.NestedValue nested: Rows.Add(new ReadOnlyRowViewModel(key, $"(중첩 속성 {nested.Children.Count}개 — 보존됨)")); break; case LegacyPropValue.BinaryValue: Rows.Add(new ReadOnlyRowViewModel(key, "(바이너리 — 원문 보존됨)")); break; case LegacyPropValue.ReferenceValue reference: Rows.Add(new ReadOnlyRowViewModel(key, $"(참조: {reference.Name})")); break; } } // 속성 추가 — 레거시 컨트롤의 임의 속성 지정(오타 주의: 로더가 모르는 키는 경고 처리) Rows.Add(new AddPropertyRowViewModel(key => { if (target.Model.Props.Contains(key)) { System.Windows.MessageBox.Show($"이미 존재하는 속성입니다: {key}", "속성 추가"); return; } designer.Undo.Snapshot(); target.Model.Props.SetText(key, string.Empty); Rebuild(); })); } private void AddDefRow(PropertyDef def) { var binding = def.Editor == PropEditorKind.StringList ? new RowBinding { Get = vm => vm.Model.Props.Get(def.Key) is LegacyPropValue.ItemsValue items ? string.Join("\n", items.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty)) : string.Empty, Set = (vm, value) => { var itemsValue = new LegacyPropValue.ItemsValue(); foreach (var line in value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { itemsValue.Items.Add(new LegacyItem { Aqn = StringItemAqn, Value = new LegacyPropValue.TextValue(line) }); } vm.Model.Props.Set(def.Key, itemsValue); }, } : new RowBinding { // 키가 없으면 def.Default 로 표시한다. Initialize 는 building 플래그로 감싸 Commit 을 내지 않으므로 // 사용자가 건드리기 전까지 PropBag 에는 아무것도 쓰이지 않는다(왕복 무손실 유지). Get = vm => vm.Model.Props.GetText(def.Key) ?? def.Default, Set = (vm, value) => { if (value.Length == 0) { vm.Model.Props.Remove(def.Key); } else { vm.Model.Props.SetText(def.Key, value); } }, }; PropertyRowViewModel row = def.Editor switch { PropEditorKind.MultilineText or PropEditorKind.StringList => new MultilineTextRowViewModel(def.Label), PropEditorKind.Number => new NumberRowViewModel(def.Label), PropEditorKind.Toggle => new ToggleRowViewModel(def.Label), PropEditorKind.Choice => new ChoiceRowViewModel(def.Label, def.Choices ?? Array.Empty()), PropEditorKind.Color => new ColorRowViewModel(def.Label), PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label, "데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags), PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label, "액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags), PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label), PropEditorKind.Mask => new MaskRowViewModel(def.Label), _ => new TextRowViewModel(def.Label), }; AddRow(row, binding); } private void AddBoundsRow(string label, Func get, Action set) { var row = new NumberRowViewModel(label); var binding = new RowBinding { Get = vm => Math.Round(get(vm)).ToString(CultureInfo.InvariantCulture), Set = (vm, value) => { if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)) { set(vm, Math.Round(number)); } }, AffectsVisual = false, }; AddRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged()); boundsRows.Add((row, binding)); } private void AddFontRow(string label, Func get, Action set) { AddRow(new TextRowViewModel(label), new RowBinding { Get = vm => get(vm.EffectiveFont), Set = (vm, value) => { var font = CurrentFontOf(vm); set(font, value); vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font)); }, }); } private void AddFontToggleRow(string label, Func get, Action set) { AddRow(new ToggleRowViewModel(label), new RowBinding { Get = vm => get(vm.EffectiveFont) ? "True" : "False", Set = (vm, value) => { var font = CurrentFontOf(vm); set(font, value == "True"); vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font)); }, }); } /// 편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화 private static LegacyFont CurrentFontOf(ControlViewModel vm) { var explicitFont = vm.Model.Props.GetText("Font"); if (explicitFont is not null) { return LegacyFormat.ParseFont(explicitFont); } var inherited = vm.EffectiveFont; return new LegacyFont { Family = inherited.Family, SizePt = inherited.SizePt, Bold = inherited.Bold, Italic = inherited.Italic, Underline = inherited.Underline, Strikeout = inherited.Strikeout, }; } private void AddRow(PropertyRowViewModel row, RowBinding binding, Action? afterCommit = null) { var items = designer.Selection.Items; var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList(); var initial = values.Count == 1 ? values[0] : null; // 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다. if (row is ChoiceRowViewModel choice) { choice.EnsureChoice(initial); } row.Initialize(initial, isMixed: values.Count > 1); row.Commit = value => { var targets = designer.Selection.Items.ToList(); if (targets.Count == 0) { return; } designer.Undo.Snapshot(); foreach (var target in targets) { binding.Set(target, value); if (binding.AffectsVisual) { target.RefreshVisual(); } target.NotifyAllChanged(); } afterCommit?.Invoke(); }; Rows.Add(row); } private void RenameControl(ControlViewModel vm, string newId) { var trimmed = newId.Trim(); if (trimmed.Length == 0 || trimmed == vm.Id) { return; } // 표(Spread)의 격자는 E_SpdMst 에서 서식코드+이름으로 조회된다. 이름을 바꾸면 조회 0건 → // 레거시가 빈 파일로 FpSpread.Open 을 시도하다 예외 → LoadObject 에 try/catch 가 없어 // 최상위로 전파되어 그 서식이 통째로 열리지 않는다. SheetMe 는 E_SpdMst 행을 만들 수 없으므로 금지한다. if (DesignerViewModel.IsSpread(vm.Model)) { System.Windows.MessageBox.Show( "표(Spread)는 이름을 바꿀 수 없습니다.\n\n" + $"격자 디자인이 서식코드+이름('{vm.Id}')으로 별도 테이블(E_SpdMst)에 저장되어 있어,\n" + "이름을 바꾸면 EMR 에서 이 서식이 열리지 않습니다.", "이름 변경", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Warning); Rebuild(); return; } var used = IdGenerator.CollectUsed(designer.Document); used.Remove(vm.Id); if (used.Contains(trimmed)) { System.Windows.MessageBox.Show($"이미 사용 중인 이름입니다: {trimmed}", "이름 변경"); // 거부했으므로 편집 상자에 남은 잘못된 이름을 원래 값으로 되돌린다 Rebuild(); return; } vm.Model.Id = trimmed; OnPropertyChanged(nameof(Summary)); } #endregion }