레거시 MaskedTextBox 는 System.Windows.Forms.MaskedTextBox 를 상속하고 Mask 를 재정의하지 않아 WinForms 기본 마스크 디자이너(프리셋 목록 + 시험 입력)를 그대로 물려받는다. SheetMe 인스펙터는 평범한 텍스트 상자뿐이라 마스크 문법을 외워야 했다. [프리셋] LegacyMaskCatalog — .NET 문화권 기본값(짧은 날짜/전화번호 등) 대신 운영 활성 디자인 전수 census 에서 실제로 쓰이는 마스크를 사용 건수와 함께 제공한다. 기존 서식과 같은 표기를 그대로 고를 수 있어 표기 흔들림이 줄어든다. 날짜/시간/날짜+시간/번호 그룹 + 표준 3종. [미리보기] 두 줄로 보여준다. 빈 값 표시 — 마스크만으로 화면에 어떻게 보이는지(ToPromptDisplay) 시험 입력 — 값을 쳐 보면 어떻게 채워지는지(Apply, 신규) 인스펙터 행에도 '빈 값 표시'를 상시 노출해 편집기를 열지 않아도 확인된다. [Apply 규칙] 구현 중 모호한 경우가 드러나 규칙을 단순하게 확정했다. 1) 리터럴은 항상 그대로 출력되고 입력을 소비하지 않는다. 2) 지시자는 입력에서 자기에게 맞는 첫 문자를 가져오며, 맞지 않는 문자와 공백은 건너뛴다. 값만 치든(20260812) 구분자를 같이 치든(2026-08-12, 2026년 08월 12일) 결과가 같아진다. 공백을 건너뛰는 이유는 '9' 가 공백도 받아들이기 때문이다 — 그대로 두면 구분자 공백이 데이터 자리를 먹어 한 칸씩 밀린다. '9' 의 공백 허용은 빈 자리를 위한 것이고 그건 프롬프트 문자가 이미 표현한다. 한계를 코드에 명시했다: 리터럴이 숫자인 마스크(2\000년 — 운영 460건 중 1건)는 사용자가 리터럴까지 치면 어디까지가 데이터인지 원리적으로 가릴 수 없다. 편집 가능한 자리만 치면 된다. 미리보기용이므로 휴리스틱을 쌓기보다 규칙을 단순하게 두는 편이 낫다고 판단했다. 안내문에서 백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호로 렌더되어 오해를 부른다. 검증: 테스트 124/124(마스크 26건 추가 — Apply 자리 채움/구분자 무시/유효성 거부/대소문자 변환자/ 이스케이프/공백 처리, 프리셋 형식·중복·운영 다빈도 포함 검사). edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0. 실제 UI 로 대화상자 열어 프리셋 그룹·건수·두 줄 미리보기 확인. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
405 lines
16 KiB
C#
405 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 속성 인스펙터 — ControlRegistry 스키마 기반 행 구성.
|
|
/// 선택 '집합' 변경 시에만 재구성(드래그 프레임 제외), 다중선택은 값 동일성 병합("여러 값").
|
|
/// 커밋 규약: 값 변경 시 Undo 스냅샷 1회 → 전체 선택 대상 적용 → 시각 재해석.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>공통/글꼴 섹션이 이미 다루는 키 — 고급 목록에서 제외</summary>
|
|
private static readonly HashSet<string> HandledKeys = new(StringComparer.Ordinal)
|
|
{
|
|
"Location", "Size", "LocationOnBase", "Name", "Font",
|
|
};
|
|
#endregion
|
|
|
|
#region Properties
|
|
/// <summary>행 목록(섹션 헤더 포함)</summary>
|
|
public ObservableCollection<PropertyRowViewModel> Rows { get; } = new();
|
|
|
|
/// <summary>선택 요약 텍스트</summary>
|
|
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
|
|
/// <summary>경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게)</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>행 재구성 — 선택 집합 변경 시</summary>
|
|
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<string>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
|
|
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> 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<string>()),
|
|
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<ControlViewModel, double> get, Action<ControlViewModel, double> 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<LegacyFont, string> get, Action<LegacyFont, string> 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<LegacyFont, bool> get, Action<LegacyFont, bool> 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));
|
|
},
|
|
});
|
|
}
|
|
|
|
/// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
|
|
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
|
|
}
|