diff --git a/src/SheetMe.Core/Catalog/BatchEditPolicy.cs b/src/SheetMe.Core/Catalog/BatchEditPolicy.cs
new file mode 100644
index 0000000..23cd992
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/BatchEditPolicy.cs
@@ -0,0 +1,327 @@
+using SheetMe.Core.Models;
+
+namespace SheetMe.Core.Catalog;
+
+///
+/// 여러 컨트롤을 함께 골랐을 때 무엇을 한 번에 바꿔도 되는가 — 판정 한 곳.
+///
+/// 왜 순수 함수 한 곳인가. 차단 근거의 대부분이 키 이름이 아니라 대상 집합에 달려 있다
+/// (타입이 섞였는가, 같은 패널의 라디오가 둘인가, 값의 형태가 대상마다 다른가).
+/// 이 판정을 UI 조건문으로 흩어 놓으면 큐레이션 경로에서 막은 것이 고급(원문) 경로로 그대로 새고,
+/// 어느 쪽이 진짜 규칙인지 아무도 모르게 된다. 여기 모아 두면 DB 없이 검사할 수 있고,
+/// 실사용 서식에서 반례가 0인 항목은 기대값을 0으로 고정할 수 있다.
+///
+/// 기준은 레거시가 열 수 있는 XML 이다. 레거시 서식생성기는 타입에 없는 속성이나 변환 불가 값을
+/// 만나면 서식을 열 때마다 오류 모달을 띄운다. 그래서 "우리 화면에서 그럴듯한가"가 아니라
+/// "레거시가 이 XML 을 조용히 열 수 있는가"로 자른다.
+///
+public static class BatchEditPolicy
+{
+ #region Member Fields
+ ///
+ /// 대상이 둘 이상이면 언제나 막는 키 — 값이 대상마다 달라야 의미가 있는 것들.
+ ///
+ /// 같은 값을 여럿에 복사하는 순간 뜻이 무너지는 세 부류다.
+ /// ① 유일해야 하는 식별자 — 이름, 서명 슬롯 번호, 탭 순서, 링크 대상.
+ /// ② 남을 이름으로 가리키는 배선 — 액션 대상 컨트롤, 합산식. 같은 값을 넣으면
+ /// 5번 문항을 누를 때 1번 문항이 초기화되는 식으로 다른 곳이 깨진다.
+ /// ③ 되돌릴 수 없는 것 — 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
+ /// Undo 스냅샷으로 덮이지 않는 유일한 부류다.
+ ///
+ private static readonly HashSet AlwaysBlocked = new(StringComparer.Ordinal)
+ {
+ // ① 유일해야 하는 것
+ "Name",
+ "SignatureIndex", // 전자동의서가 이 값으로 서명 슬롯을 배정한다 — 같으면 보호자 칸에 환자 서명이 간다
+ "DisplaySequence",
+ "TabIndex",
+ "LinkToNewDesign",
+ "SMSWebControlName",
+ // 저장 시 writer 가 Bounds/Id 에서 다시 만들어 덮는 키 — 고쳐도 저장하면 되돌아간다
+ "Location",
+ "Size",
+ "LocationOnBase",
+ // ② 남을 이름으로 가리키는 배선
+ "DataActionTagControl",
+ "GetDataActionTagControl",
+ "GetDataActionTagControlChange",
+ "AssociatedControl",
+ "Formula",
+ "FormulaResultCheck",
+ "EventHandlerMappingTag",
+ // ③ 되돌릴 수 없는 것 — 일련번호 발급
+ "SequenceCode",
+ "SequenceType",
+ "SequenceNumber",
+ "CheckSequenceAtSaving",
+ };
+
+ ///
+ /// 타입이 섞였을 때만 막는 키 — 같은 타입끼리는 정상 작업이다.
+ ///
+ /// TextAlign 이 대표다. 같은 이름이지만 어휘가 다르다 — 라벨·체크·라디오·버튼은
+ /// ContentAlignment(TopLeft…BottomRight 9값), 텍스트박스·마스크박스는
+ /// HorizontalAlignment(Left/Center/Right 3값)다. 섞어서 "MiddleCenter" 를 주면
+ /// 텍스트박스 쪽에는 레거시가 읽을 수 없는 값이 들어간다.
+ ///
+ private static readonly HashSet BlockedWhenTypesDiffer = new(StringComparer.Ordinal)
+ {
+ "TextAlign",
+ // 태그는 타입마다 채우는 방식이 다르다(체크류는 Y/TRUE 판정, 글자류는 문자열) —
+ // 섞어서 같은 태그를 주면 한쪽은 값이, 다른 쪽은 체크가 되어 뜻이 갈린다
+ "DataInterfaceTag",
+ };
+
+ ///
+ /// 고급(원문) 경로에서만 막는 키 — 큐레이션 경로는 속성과 크기를 함께 고치므로 안전하다.
+ ///
+ /// 선의 방향·굵기가 그렇다. 인스펙터의 전용 행은 컨트롤마다 LineGeometry 를 태워
+ /// 각 선의 길이를 보존한 채 축을 바꾼다. 반면 원문 텍스트 행은 속성만 쓰고 크기를 그대로 두어,
+ /// XML 은 Vertical 인데 경계는 200×1 인 선이 된다 — EMR 은 그것을 1px 점으로 그린다.
+ ///
+ private static readonly HashSet BlockedInRawOnly = new(StringComparer.Ordinal)
+ {
+ "Orientation",
+ "BorderWidth",
+ };
+
+ ///
+ /// 타입마다 라벨이 갈리는 키의 중립 라벨 — 섞였을 때 한쪽 타입의 말을 쓰지 않는다.
+ /// 예를 들어 체크박스의 "기본 체크"를 라디오까지 포함한 선택에 그대로 쓰면 어색하다.
+ ///
+ private static readonly Dictionary NeutralLabels = new(StringComparer.Ordinal)
+ {
+ ["Checked"] = "기본값",
+ ["Text"] = "텍스트",
+ ["Items"] = "항목(줄바꿈 구분)",
+ };
+ #endregion
+
+ #region Types
+ /// 일괄 편집 허용 여부와, 막았다면 사람이 읽을 이유
+ public readonly record struct BatchVerdict(bool Allowed, string? Reason)
+ {
+ /// 허용
+ public static BatchVerdict Ok => new(true, null);
+
+ /// 차단 — 이유는 인스펙터에 그대로 보인다
+ public static BatchVerdict No(string reason) => new(false, reason);
+ }
+ #endregion
+
+ #region Methods
+ ///
+ /// 선택된 타입들이 모두 갖는 속성 — 이것이 "공통 속성"이다.
+ ///
+ /// 키 이름만 같으면 안 된다. 편집기 종류·선택지·기본값까지 같아야 한 칸으로 묶을 수 있다.
+ /// 하나라도 다르면 그 칸의 값이 대상마다 다른 뜻이 되고, 사용자는 그 사실을 알 방법이 없다.
+ /// · TextAlign — 선택지가 9값 대 3값이라 탈락(섞으면 한쪽에 없는 값이 들어간다).
+ /// · BorderStyle — 기본값이 "Fixed3D" 대 "None" 이라 탈락. 기본값이 갈리면
+ /// 키가 없는 대상이 남의 기본값으로 보이고, 갈렸는지 여부 판정부터 틀어진다.
+ /// · visible/Visible — 라벨만 소문자다(레거시가 그림자 속성을 쓴다).
+ /// 키 문자열이 다르므로 자연히 탈락한다. 별칭으로 묶지 않는다 — 묶으면 라벨에
+ /// 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
+ ///
+ /// 스키마를 모르는 타입(표·격자 등 descriptor 가 없는 것)이 하나라도 섞이면 빈 집합이다.
+ /// 무엇이 공통인지 말할 근거가 없는데 추측해서 열면 그게 손상 경로가 된다.
+ ///
+ public static IReadOnlyList MergeCommonDefs(IReadOnlyList types)
+ {
+ if (types.Count == 0)
+ {
+ return Array.Empty();
+ }
+ var descriptors = new List(types.Count);
+ foreach (var type in types)
+ {
+ if (ControlRegistry.Find(type) is not { } descriptor)
+ {
+ return Array.Empty();
+ }
+ descriptors.Add(descriptor);
+ }
+ if (descriptors.Count == 1)
+ {
+ return descriptors[0].Properties;
+ }
+
+ var merged = new List();
+ // 기준은 첫 타입의 선언 순서 — 인스펙터 행 순서가 실행마다 달라지면 안 된다
+ foreach (var def in descriptors[0].Properties)
+ {
+ var common = true;
+ var labelDiffers = false;
+ for (var i = 1; i < descriptors.Count && common; i++)
+ {
+ var other = descriptors[i].Properties.FirstOrDefault(p => string.Equals(p.Key, def.Key, StringComparison.Ordinal));
+ if (other is null || other.Editor != def.Editor
+ || !SameChoices(def.Choices, other.Choices)
+ || !string.Equals(def.Default, other.Default, StringComparison.Ordinal))
+ {
+ common = false;
+ break;
+ }
+ labelDiffers |= !string.Equals(def.Label, other.Label, StringComparison.Ordinal);
+ }
+ if (!common)
+ {
+ continue;
+ }
+ merged.Add(labelDiffers && NeutralLabels.TryGetValue(def.Key, out var neutral)
+ ? Relabel(def, neutral)
+ : def);
+ }
+ return merged;
+ }
+
+ ///
+ /// 어느 타입엔가 있지만 공통으로 올리지 못한 키의 수 — 인스펙터가 "함께 못 바꾸는 것이 있다"고 알린다.
+ /// 조용히 빼면 사용자는 그 속성이 존재하지 않는다고 읽는다.
+ ///
+ public static int ExcludedCount(IReadOnlyList types)
+ {
+ if (types.Count < 2)
+ {
+ return 0;
+ }
+ var all = new HashSet(StringComparer.Ordinal);
+ foreach (var type in types)
+ {
+ if (ControlRegistry.Find(type) is not { } descriptor)
+ {
+ continue;
+ }
+ foreach (var def in descriptor.Properties)
+ {
+ all.Add(def.Key);
+ }
+ }
+ foreach (var def in MergeCommonDefs(types))
+ {
+ all.Remove(def.Key);
+ }
+ return all.Count;
+ }
+
+ ///
+ /// 이 키를 이 대상들에 한 번에 써도 되는가.
+ ///
+ /// PropBag 키
+ /// 선택된 컨트롤 모델
+ /// 고급(원문 텍스트) 경로인가 — 전용 편집기의 보정을 타지 않는다
+ /// 컨트롤 → 직계 부모(최상위면 null). 라디오 배타 판정에 쓴다.
+ public static BatchVerdict IsBatchable(string key, IReadOnlyList targets,
+ bool viaAdvancedRaw = false, Func? parentOf = null)
+ {
+ // 대상이 하나면 단일 선택과 똑같이 둔다 — 이 기능이 기존 동작을 좁히면 안 된다
+ if (targets.Count <= 1)
+ {
+ return BatchVerdict.Ok;
+ }
+ if (AlwaysBlocked.Contains(key))
+ {
+ return BatchVerdict.No("대상마다 값이 달라야 하는 속성입니다");
+ }
+ if (viaAdvancedRaw && BlockedInRawOnly.Contains(key))
+ {
+ return BatchVerdict.No("원문으로 바꾸면 크기가 따라오지 않습니다 — 전용 편집기를 쓰세요");
+ }
+ var types = targets.Select(t => t.Type).Distinct(StringComparer.Ordinal).Count();
+ if (types > 1 && BlockedWhenTypesDiffer.Contains(key))
+ {
+ return BatchVerdict.No("타입마다 값의 뜻이 다릅니다 — 타입별로 따로 고르세요");
+ }
+ // 라디오의 기본 선택 — 같은 부모에 둘 이상이면 한 문항의 답이 둘이 된다.
+ // 레거시 라디오는 GroupName 이 없어 배타를 직계 부모에 맡기는데, 로더가 자식 속성을
+ // 부모에 붙이기 전에 대입하므로 형제가 꺼지지 않는다 — 저장도 되고 그대로 열린다.
+ // 캔버스에는 아무 신호가 없고, 종이에는 ● 가 둘 찍힌다.
+ if (string.Equals(key, "Checked", StringComparison.Ordinal) && HasRadioSiblings(targets, parentOf))
+ {
+ return BatchVerdict.No("같은 묶음의 라디오는 하나만 선택될 수 있습니다");
+ }
+ return BatchVerdict.Ok;
+ }
+
+ ///
+ /// 대상 전부가 이 키를 갖고, 전부 문자열 형태인가 — 고급(원문) 행을 열어도 되는 조건.
+ ///
+ /// 둘 다 필요하다.
+ /// · 전부 갖고 있어야 한다. 없던 대상에 키가 생기면 레거시 서식생성기가
+ /// "이 타입에 없는 속성"이라며 열 때마다 모달을 띄운다.
+ /// · 전부 문자열이어야 한다. 이미지·중첩·참조 값은 텍스트로 바꾸는 순간 원문이 사라진다.
+ /// GetText 가 문자열이 아닌 값에 null 을 돌려주므로 빈 값과 이미지가 둘 다 "" 로 접혀
+ /// 갈렸다는 표시조차 나오지 않는다 — 경고 없이 base64 원문이 교체된다.
+ ///
+ public static bool AllShareTextShape(string key, IReadOnlyList targets)
+ {
+ foreach (var target in targets)
+ {
+ if (!target.Props.Contains(key))
+ {
+ return false;
+ }
+ if (target.Props.Get(key) is not (LegacyPropValue.TextValue or LegacyPropValue.NullValue))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /// 이 키를 가진 대상 수 — "3개 중 1개에만 있음" 안내용
+ public static int CountHaving(string key, IReadOnlyList targets)
+ => targets.Count(t => t.Props.Contains(key));
+
+ /// 선택지 동일성 — 원소와 순서까지. 참조 비교로는 절대 판정할 수 없다.
+ private static bool SameChoices(string[]? left, string[]? right)
+ {
+ if (left is null || right is null)
+ {
+ return left is null && right is null;
+ }
+ return left.SequenceEqual(right, StringComparer.Ordinal);
+ }
+
+ ///
+ /// 라벨만 바꾼 복제 — 는 record 가 아니라 with 를 쓸 수 없다.
+ /// 선택지 배열은 복사하지 않고 참조만 넘긴다(레지스트리의 공개 값이라 절대 수정하지 않는다).
+ ///
+ private static PropertyDef Relabel(PropertyDef def, string label) => new()
+ {
+ Key = def.Key,
+ Label = label,
+ Editor = def.Editor,
+ Choices = def.Choices,
+ Default = def.Default,
+ };
+
+ /// 같은 직계 부모를 공유하는 라디오가 둘 이상 들어 있는가
+ private static bool HasRadioSiblings(IReadOnlyList targets,
+ Func? parentOf)
+ {
+ var seen = new List<(ControlElement? Parent, int Count)>();
+ foreach (var target in targets)
+ {
+ if (!string.Equals(target.Type, "RadioButton", StringComparison.Ordinal))
+ {
+ continue;
+ }
+ // 부모를 알 수 없으면(맵 미제공) 전부 같은 묶음으로 본다 — 모르면 막는 쪽이다.
+ // 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고, 그건 화면으로 확인할 수 없다.
+ var parent = parentOf?.Invoke(target);
+ var index = seen.FindIndex(entry => ReferenceEquals(entry.Parent, parent));
+ if (index < 0)
+ {
+ seen.Add((parent, 1));
+ continue;
+ }
+ if (seen[index].Count + 1 >= 2)
+ {
+ return true;
+ }
+ seen[index] = (parent, seen[index].Count + 1);
+ }
+ return false;
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Controls/ChoiceLabelConverter.cs b/src/SheetMe.Designer/Controls/ChoiceLabelConverter.cs
new file mode 100644
index 0000000..22dea2e
--- /dev/null
+++ b/src/SheetMe.Designer/Controls/ChoiceLabelConverter.cs
@@ -0,0 +1,25 @@
+using System.Globalization;
+using System.Windows.Data;
+
+namespace SheetMe.Designer.Controls;
+
+///
+/// 선택지 목록의 표시 글자 — 빈 항목만 "여러 값"으로 바꾼다.
+///
+/// 값이 갈린 다중 선택에서 가
+/// 목록 맨 앞에 빈 문자열 자리를 만든다. 그대로 그리면 목록에 빈 줄이 하나 보일 뿐이라
+/// 무엇인지 알 수 없고, 실수로 고르면 "값을 지우는 것"으로 읽힌다.
+/// 나머지 값은 레거시 원문 열거값 그대로 둔다 — 저장되는 문자열과 화면 글자가 같아야
+/// 사용자가 XML 을 열어 봤을 때 대조할 수 있다.
+///
+public sealed class ChoiceLabelConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ var text = value as string ?? string.Empty;
+ return text.Length == 0 ? "여러 값" : text;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index e095264..5a71b9e 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -1026,6 +1026,176 @@ public static class EditSmoke
Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo);
}
+ // 15-b) 값이 갈린 행이 사용자가 건드리기 전에 커밋을 내지 않는가 — 진짜 컨트롤·진짜 바인딩으로.
+ //
+ // 여기서 실제로 확인한 것. 갈린 토글을 한 번 누르면 무엇이 되는지가 핵심이다.
+ // WPF ToggleButton 은 IsChecked 가 null 이면 IsThreeState 와 무관하게 false 로 간다.
+ // 고치기 전 실측: 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False.
+ // 즉 굵게를 켜려고 누른 한 번이 선택 전체의 굵게를 껐다. 세그먼트 토글에는
+ // 갈림 표시도 없어(꺼짐과 픽셀 단위로 동일) 무엇이 일어났는지 볼 수도 없었다.
+ //
+ // 확인하지 못한 것. ComboBox.SelectedItem 의 코어스 되쓰기(목록에 없는 값을 null 로
+ // 바꿔 소스에 되쓰는 것)는 이 진단에서 재현되지 않았다 — 목록 밖 값을 넣은
+ // 양성 대조에서도 되쓰기가 관측되지 않아, 이 검사는 그 경로에 대해 둔감하다.
+ // 그래서 '되쓰기가 없다'를 여기서 주장하지 않는다. 대신 ChoiceRow 는 갈렸을 때
+ // 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할 조건 자체를 없앤다.
+ {
+ var choiceRow = new ViewModels.Inspector.ChoiceRowViewModel("정렬",
+ new[] { "Horizontal", "Vertical" });
+ var fired = new List();
+ choiceRow.Commit = value => fired.Add(value ?? "(null)");
+ // BindRow 가 갈린 값에 대해 하는 것과 같은 순서로 준비한다
+ choiceRow.EnsureMixedPlaceholder();
+ choiceRow.Initialize(null, isMixed: true); // 값이 갈린 상태 = 다중 선택의 기본형
+
+ var combo = new System.Windows.Controls.ComboBox { DataContext = choiceRow };
+ combo.SetBinding(System.Windows.Controls.ComboBox.ItemsSourceProperty,
+ new System.Windows.Data.Binding(nameof(ViewModels.Inspector.ChoiceRowViewModel.Choices)));
+ combo.SetBinding(System.Windows.Controls.Primitives.Selector.SelectedItemProperty,
+ new System.Windows.Data.Binding(nameof(ViewModels.Inspector.PropertyRowViewModel.ValueText)));
+ // 바인딩을 실제로 활성화한다 — 붙이기만 하면 Selector 의 코어스가 아직 안 돈다
+ combo.Measure(new System.Windows.Size(200, 30));
+ combo.Arrange(new Rect(0, 0, 200, 30));
+ combo.UpdateLayout();
+
+ Check("혼합: 갈린 선택지 행이 스스로 커밋하지 않는다", fired.Count == 0,
+ fired.Count == 0 ? "커밋 0회" : $"커밋 {fired.Count}회 — 값 [{string.Join(", ", fired)}]");
+
+ // 갈린 선택지 행에 '여러 값' 자리가 생겼는가 — 빈 칸이 '값 없음'과 구별되어야 한다
+ Check("혼합: 갈린 선택지에 '여러 값' 자리가 생긴다",
+ choiceRow.Choices.Count == 3 && choiceRow.Choices[0].Length == 0,
+ $"[{string.Join(",", choiceRow.Choices.Select(c => c.Length == 0 ? "(빈칸)" : c))}]");
+
+ // 갈린 토글을 한 번 누르면 켜져야 한다.
+ // WPF ToggleButton.OnToggle 은 IsChecked==null 이면 IsThreeState 와 무관하게 false 로 간다
+ // (isChecked.HasValue → null 이면 false). 그대로 두면 굵게를 켜려고 누른 한 번이
+ // 선택 전체를 '굵게 끔' 으로 커밋한다 — 고치기 전 실측이 정확히 그랬다.
+ var styleToggle = new ViewModels.Inspector.ToggleRowViewModel("굵게");
+ var styleFired = new List();
+ styleToggle.Commit = value => styleFired.Add(value);
+ styleToggle.Initialize(null, isMixed: true);
+ var button = new System.Windows.Controls.Primitives.ToggleButton { DataContext = styleToggle };
+ button.SetBinding(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty,
+ new System.Windows.Data.Binding(nameof(ViewModels.Inspector.ToggleRowViewModel.IsOn)));
+ button.SetBinding(System.Windows.Controls.Primitives.ToggleButton.IsThreeStateProperty,
+ new System.Windows.Data.Binding(nameof(ViewModels.Inspector.PropertyRowViewModel.IsMixed))
+ { Mode = System.Windows.Data.BindingMode.OneWay });
+ button.Measure(new System.Windows.Size(40, 26));
+ button.Arrange(new Rect(0, 0, 40, 26));
+ var beforeClick = button.IsChecked;
+ // 실제 클릭과 같은 경로 — 자동화 피어의 Toggle 이 ToggleButton.OnToggle 을 부른다
+ ((System.Windows.Automation.Provider.IToggleProvider)
+ new System.Windows.Automation.Peers.ToggleButtonAutomationPeer(button)).Toggle();
+ Check("혼합: 갈린 토글을 한 번 누르면 켜진다(끄지 않는다)",
+ styleFired.Count == 1 && styleFired[0] == "True",
+ $"누르기 전 IsChecked={beforeClick?.ToString() ?? "null"}, "
+ + $"커밋 [{string.Join(",", styleFired)}], 누른 뒤 IsChecked={button.IsChecked?.ToString() ?? "null"}");
+ Check("혼합: 갈린 선택지 행의 값이 되밀리지 않는다", choiceRow.ValueText.Length == 0
+ && choiceRow.IsMixed,
+ $"ValueText='{choiceRow.ValueText}' IsMixed={choiceRow.IsMixed}");
+ }
+
+ // 15-c) 여러 개를 함께 고르고 공통 속성을 한 번에 바꾼다.
+ //
+ // 전에는 타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다. 그런데 여러 개를
+ // 함께 고쳐야 하는 속성(읽기 전용·인쇄 출력·필수 입력)이 정확히 거기 있었고,
+ // 없는 이유를 알려 주는 표시도 없어 '이 도구로는 안 되는 일'로 보였다.
+ {
+ var bulk = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
+ bulk.AddControlAt("Label", new Point(60, 60));
+ bulk.AddControlAt("TextBox", new Point(60, 120));
+ bulk.AddControlAt("CheckBox", new Point(60, 180));
+ var all = bulk.Pages[0].Controls.ToList();
+ bulk.Selection.Set(all, all[0]);
+
+ var inspector = bulk.Inspector;
+ Check("일괄: 타입이 섞여도 공통 섹션이 나온다",
+ inspector.Rows.OfType()
+ .Any(s => s.Label.StartsWith("공통 속성", StringComparison.Ordinal)),
+ string.Join(" / ", inspector.Rows.OfType().Select(s => s.Label)));
+
+ // 라벨에는 '읽기 전용'이 없다(입력 컨트롤이 아니라 레거시도 노출하지 않는다) —
+ // 교집합이 제대로 좁혀졌는지부터 본다
+ Check("일괄: 한쪽에만 있는 속성은 공통에 오지 않는다",
+ !inspector.Rows.Any(r => r.Label == "읽기 전용"),
+ string.Join(" / ", inspector.Rows.Select(r => r.Label)));
+
+ // 정렬은 타입마다 어휘가 다르다(라벨 9값 ContentAlignment vs 텍스트박스 3값 HorizontalAlignment).
+ // 함께 올리면 텍스트박스에 레거시가 못 읽는 값이 들어간다.
+ inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Design;
+ Check("일괄: 뜻이 다른 정렬은 공통에 올리지 않는다",
+ !inspector.Rows.Any(r => r.Label == "정렬"),
+ string.Join(" / ", inspector.Rows.Select(r => r.Label)));
+ Check("일괄: 함께 못 바꾸는 것이 있다고 알린다",
+ inspector.Rows.OfType()
+ .Any(r => r.Label == "함께 바꿀 수 없음"));
+
+ // 이름은 유일해야 한다 — 풀면 두 번째 대상부터 충돌 모달이 뜨고
+ // 그 거절이 커밋 루프를 멈추지 못한다
+ Check("일괄: 다중 선택에는 이름 행이 없다",
+ !inspector.Rows.Any(r => r.Label == "이름"));
+
+ // 이 기능이 존재하는 이유 — 읽기 전용·필수 입력·재조회는 동작/데이터 탭에 있다.
+ // 전에는 타입이 섞이면 그 탭이 접혀, 공통 섹션을 아무리 잘 만들어도 닿을 수 없었다.
+ // 셋 다 '읽기 전용'을 갖는 조합으로 확인한다.
+ bulk.AddControlAt("ComboBox", new Point(60, 240));
+ bulk.AddControlAt("DateTimePicker", new Point(60, 300));
+ var editable = bulk.Pages[0].Controls
+ .Where(c => c.Type is "TextBox" or "ComboBox" or "DateTimePicker").ToList();
+ bulk.Selection.Set(editable, editable[0]);
+ Check("일괄: 타입이 섞여도 동작 탭이 남는다", inspector.ShowBehaviorTab,
+ $"동작 {inspector.BehaviorCount}개");
+ inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Behavior;
+ var preventRow = inspector.Rows.OfType()
+ .FirstOrDefault(r => r.Label == "읽기 전용");
+ Check("일괄: 동작 탭에 공통 행이 실제로 있다", preventRow is not null,
+ string.Join(" / ", inspector.Rows.Select(r => r.Label)));
+
+ if (preventRow is not null)
+ {
+ preventRow.ValueText = "True";
+ Check("일괄: 한 번 바꾸면 선택 전체에 들어간다",
+ editable.All(c => c.Model.Props.GetText("PreventEditing") == "True"),
+ string.Join(",", editable.Select(c => $"{c.Type}={c.Model.Props.GetText("PreventEditing")}")));
+ Check("일괄: 대상이 몇 개든 Undo 는 한 스텝", bulk.Undo.CanUndo);
+ bulk.Undo.Undo();
+ Check("일괄: Undo 한 번으로 전부 원복된다",
+ bulk.Pages[0].Controls.All(c => c.Model.Props.GetText("PreventEditing") is null),
+ string.Join(",", bulk.Pages[0].Controls.Select(c => c.Model.Props.GetText("PreventEditing") ?? "없음")));
+ // 되돌린 뒤에도 선택이 살아 있어야 '바꾸기 → 확인 → 되돌리기 → 다시'가 이어진다.
+ // 전에는 문서 교체가 선택을 통째로 지워, 첫 되돌리기에서 흐름이 끊겼다.
+ Check("일괄: Undo 후에도 선택이 남는다", bulk.Selection.Items.Count == editable.Count,
+ $"{bulk.Selection.Items.Count}개 (기대 {editable.Count})");
+ }
+ inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Design;
+
+ // 같은 타입만 고르면 그 타입의 전용 섹션이 그대로 나와야 한다(기존 동작 유지)
+ bulk.AddControlAt("TextBox", new Point(60, 360));
+ var boxes = bulk.Pages[0].Controls.Where(c => c.Type == "TextBox").ToList();
+ bulk.Selection.Set(boxes, boxes[0]);
+ Check("일괄: 같은 타입끼리는 전용 섹션 이름이 유지된다",
+ inspector.Rows.OfType()
+ .Any(s => s.Label == "텍스트박스"),
+ string.Join(" / ", inspector.Rows.OfType().Select(s => s.Label)));
+ Check("일괄: 같은 타입끼리는 정렬을 함께 바꿀 수 있다",
+ inspector.Rows.Any(r => r.Label == "정렬"));
+
+ // 바뀐 것이 없으면 문서를 '수정됨'으로 만들지 않는다 —
+ // 전에는 스냅샷만 쌓여, 그것을 지우려 누른 Ctrl+Z 가 선택까지 지웠다
+ var fresh = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
+ fresh.AddControlAt("Label", new Point(60, 60));
+ fresh.AddControlAt("Label", new Point(60, 120));
+ fresh.Selection.Set(fresh.Pages[0].Controls.ToList(), fresh.Pages[0].Controls[0]);
+ fresh.Undo.MarkSaved();
+ var xRow = (ViewModels.Inspector.NumberRowViewModel)fresh.Inspector.Rows
+ .OfType().First(p => p.LeftLabel == "X").Left;
+ xRow.ValueText = "100px"; // 숫자가 아니다
+ Check("일괄: 숫자가 아니면 커밋하지 않는다(문서가 수정되지 않는다)",
+ !fresh.Undo.IsDirty, $"dirty={fresh.Undo.IsDirty}");
+ Check("일괄: 거절한 값은 칸에 남지 않는다", xRow.ValueText != "100px",
+ $"칸='{xRow.ValueText}'");
+ }
+
// 16) 인스펙터 — 이름 변경
var nameRow = designer.Inspector.Rows
.OfType()
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index 8af438a..48194fb 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -577,11 +577,58 @@ public sealed class DesignerViewModel : ViewModelBase
RefreshOverlay();
}
- /// 문서 교체 후 재구성 — Undo 복원 경로
+ ///
+ /// 문서 교체 후 재구성 — Undo 복원 경로.
+ ///
+ /// 선택을 되살린다. 재구성의 첫 줄이 Selection.Clear() 라, 전에는 되돌리기 한 번마다
+ /// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이
+ /// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데
+ /// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다.
+ ///
+ /// 기준은 Id 다 — 문서 안에서 유일하고(IdGenerator.CollectUsed) 문서를 갈아끼워도 같다.
+ /// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다).
+ ///
public void ReplaceDocument(FormDocument newDocument)
{
+ var keepIds = Selection.Items.Select(vm => vm.Id).ToList();
+ var primaryId = Selection.Primary?.Id;
+
document = newDocument;
RebuildFromDocument();
+
+ if (keepIds.Count == 0)
+ {
+ return;
+ }
+ var wanted = new HashSet(keepIds, StringComparer.OrdinalIgnoreCase);
+ var found = new List();
+ ControlViewModel? primary = null;
+ void Visit(IEnumerable controls)
+ {
+ foreach (var vm in controls)
+ {
+ if (wanted.Contains(vm.Id))
+ {
+ found.Add(vm);
+ if (primaryId is not null && string.Equals(vm.Id, primaryId, StringComparison.OrdinalIgnoreCase))
+ {
+ primary = vm;
+ }
+ }
+ if (vm is ContainerViewModel container)
+ {
+ Visit(container.Children);
+ }
+ }
+ }
+ foreach (var page in Pages)
+ {
+ Visit(page.Controls);
+ }
+ if (found.Count > 0)
+ {
+ Selection.Set(found, primary ?? found[0]);
+ }
}
/// 페이지 세로 오프셋 재계산
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
index f12b38e..238d384 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
@@ -388,7 +388,7 @@ public sealed class InspectorViewModel : ViewModelBase
var items = designer.Selection.Items;
if (items.Count == 0)
{
- RefreshTabCounts(null);
+ RefreshTabCounts(Array.Empty(), 0);
if (designer.InspectedPage is { } page)
{
BuildPageRows(page);
@@ -397,10 +397,22 @@ public sealed class InspectorViewModel : ViewModelBase
return;
}
- // 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다
- var type = items[0].Type;
- var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null;
- RefreshTabCounts(descriptor);
+ // 편집할 속성 집합을 먼저 정하고, 그 결과로 탭을 정한다.
+ //
+ // 순서가 중요하다. 전에는 "타입이 전부 같은가"만 보고 탭 가시성을 정했는데,
+ // 타입이 섞이면 descriptor 가 null 이라 데이터·동작 탭이 통째로 접혔다.
+ // 그런데 여러 개를 함께 고쳐야 하는 속성(읽기 전용·필수 입력·재조회·데이터 배선)이
+ // 정확히 그 두 탭에 있다 — 공통 섹션을 아무리 잘 만들어도 화면에는 글꼴과 좌표만 남고
+ // 왜 없는지 알려 주는 표시도 없었다.
+ var models = items.Select(i => i.Model).ToList();
+ var types = items.Select(i => i.Type).Distinct(StringComparer.Ordinal).ToList();
+ var descriptor = types.Count == 1 ? ControlRegistry.Find(types[0]) : null;
+ // MergeCommonDefs 는 타입이 하나면 그 타입의 속성을 그대로, 스키마를 모르는 타입이
+ // 섞이면 빈 집합을 돌려준다 — 여기서 갈래를 나눌 필요가 없다.
+ var merged = BatchEditPolicy.MergeCommonDefs(types)
+ .Where(def => BatchEditPolicy.IsBatchable(def.Key, models, viaAdvancedRaw: false, ParentModelOf).Allowed)
+ .ToList();
+ RefreshTabCounts(merged, items.Count);
if (selectedTab == InspectorTab.Design)
{
@@ -429,33 +441,46 @@ public sealed class InspectorViewModel : ViewModelBase
AddFontStyleSegment();
}
- // 타입 전용 — 선택 탭에 해당하는 속성만
+ // 타입 전용(또는 타입이 섞였으면 공통) — 선택 탭에 해당하는 속성만
var curatedKeys = new HashSet(HandledKeys, StringComparer.Ordinal);
- if (descriptor is not null)
+ if (merged.Count > 0)
{
// 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라
// 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다.
- foreach (var def in descriptor.Properties)
+ foreach (var def in merged)
{
curatedKeys.Add(def.Key);
}
- var tabDefs = descriptor.Properties.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
+ var tabDefs = merged.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
if (tabDefs.Count > 0)
{
- AddSection(descriptor.DisplayName);
+ // 섹션 제목에 한 타입의 이름을 쓰면 안 된다 — 라벨과 텍스트박스를 함께 고른
+ // 선택에 '라벨'이라는 제목이 붙으면 텍스트박스는 안 바뀐다고 읽는다.
+ AddSection(types.Count == 1 && descriptor is not null
+ ? descriptor.DisplayName
+ : $"공통 속성 ({types.Count}종 타입)");
foreach (var def in tabDefs)
{
- AddDefRow(def, descriptor.Type);
+ // 태그 제안은 타입별로 후보가 갈린다 — 섞였으면 한쪽으로 편향시키지 않는다
+ AddDefRow(def, types.Count == 1 ? types[0] : string.Empty, models);
+ }
+ // 함께 못 바꾸는 것이 있으면 말한다. 조용히 빼면 그 속성이 없다고 읽는다.
+ var excluded = BatchEditPolicy.ExcludedCount(types);
+ if (excluded > 0)
+ {
+ AddPlain(new ReadOnlyRowViewModel("함께 바꿀 수 없음",
+ $"타입이 달라 값의 뜻이 다른 속성 {excluded}개 — 타입별로 따로 고르세요"));
}
}
}
- // 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가).
+ // 전체 속성(고급) — PropBag 의 나머지 레거시 원문 속성(레거시 PropertyGrid 등가).
// 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다.
- if (items.Count == 1)
+ // 표(Spread)가 섞이면 열지 않는다 — 격자 디자인이 별도 테이블에 있어 원문 편집의 파장을 모른다.
+ if (!items.Any(i => DesignerViewModel.IsSpread(i.Model)))
{
- BuildAdvancedRows(items[0], curatedKeys);
+ BuildAdvancedRows(items, curatedKeys);
}
OnPropertyChanged(nameof(IsTabEmpty));
@@ -469,11 +494,13 @@ public sealed class InspectorViewModel : ViewModelBase
/// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만
/// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다.
///
- private void RefreshTabCounts(ControlDescriptor? descriptor)
+ private void RefreshTabCounts(IReadOnlyList typed, int selectionCount)
{
- var typed = descriptor?.Properties ?? (IReadOnlyList)Array.Empty();
- // 디자인 탭에는 공통 5 + 글꼴 6 이 항상 붙는다 → 선택이 있으면 절대 비지 않는다
- DesignCount = 11 + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
+ // 디자인 탭에는 공통 + 글꼴이 항상 붙는다 → 선택이 있으면 절대 비지 않는다.
+ // 다중 선택에는 이름 행이 없으므로(이름은 유일해야 한다) 하나 적다 —
+ // 전에는 11 로 굳어 있어 다중 선택에서 실제 행 수와 어긋났다.
+ DesignCount = (selectionCount == 1 ? 11 : 10)
+ + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data);
BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior);
@@ -665,12 +692,41 @@ public sealed class InspectorViewModel : ViewModelBase
}, () => designer.RefreshPageInheritance(page));
}
- /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가
- private void BuildAdvancedRows(ControlViewModel target, HashSet curatedKeys)
+ ///
+ /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가.
+ ///
+ /// 다중 선택에서 위험한 곳이 여기다. 전에는 행의 종류·편집 여부·키 목록을 전부
+ /// items[0] 하나로 정하면서 커밋은 선택 전체에 썼다. 그래서 이미지·중첩·참조 값을
+ /// 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고, 클릭 순서에 따라 같은 조작이
+ /// 안전해지거나 파괴적이 됐다. 게다가 GetText 는 문자열이 아닌 값에 null 을 돌려주므로
+ /// 빈 값과 이미지 원문이 둘 다 "" 로 접혀 갈렸다는 표시조차 나오지 않았다.
+ ///
+ /// 그래서 판정을 대상 전체로 바꾼다 — 전부 갖고 있고 전부 문자열일 때만 편집을 연다.
+ /// 레거시 PropertyGrid 의 병합 규칙(이름과 타입이 모든 대상에 공통일 때만 행이 생긴다)과 같은 결이다.
+ ///
+ private void BuildAdvancedRows(IReadOnlyList targets, HashSet curatedKeys)
{
- var advancedKeys = target.Model.Props.Keys
- .Where(k => !curatedKeys.Contains(k))
- .ToList();
+ if (targets.Count == 0)
+ {
+ return;
+ }
+ var target = targets[0];
+ var models = targets.Select(t => t.Model).ToList();
+ var single = targets.Count == 1;
+ // 키 목록은 합집합이다(선언 순서 보존) — 대상마다 가진 키가 다를 수 있고,
+ // 첫 컨트롤만 훑으면 나머지가 가진 속성은 존재조차 보이지 않는다.
+ var advancedKeys = new List();
+ var seenKeys = new HashSet(StringComparer.Ordinal);
+ foreach (var model in models)
+ {
+ foreach (var key in model.Props.Keys)
+ {
+ if (!curatedKeys.Contains(key) && seenKeys.Add(key))
+ {
+ advancedKeys.Add(key);
+ }
+ }
+ }
if (!showAdvanced)
{
@@ -692,6 +748,36 @@ public sealed class InspectorViewModel : ViewModelBase
foreach (var key in advancedKeys)
{
+ // 다중 선택은 대상 전체를 보고 판정한다 — 셋 다 통과해야 편집을 연다.
+ if (!single)
+ {
+ if (BatchEditPolicy.IsBatchable(key, models, viaAdvancedRaw: true, ParentModelOf) is { Allowed: false } verdict)
+ {
+ AddPlain(new ReadOnlyRowViewModel(key, $"(함께 바꿀 수 없음 — {verdict.Reason})"));
+ continue;
+ }
+ var having = BatchEditPolicy.CountHaving(key, models);
+ if (having < models.Count)
+ {
+ // 없던 대상에 키를 만들면 레거시 서식생성기가 열 때마다 '이 타입에 없는 속성'
+ // 이라며 모달을 띄운다 — 만들지 않는다
+ AddPlain(new ReadOnlyRowViewModel(key,
+ $"({models.Count}개 중 {having}개에만 있음 — 함께 바꿀 수 없음)"));
+ continue;
+ }
+ if (!BatchEditPolicy.AllShareTextShape(key, models))
+ {
+ AddPlain(new ReadOnlyRowViewModel(key, "(대상마다 값의 형태가 다릅니다 — 함께 바꿀 수 없음)"));
+ continue;
+ }
+ AddRow(new TextRowViewModel(key), new RowBinding
+ {
+ Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
+ Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
+ });
+ continue;
+ }
+
var value = target.Model.Props.Get(key);
switch (value)
{
@@ -736,6 +822,13 @@ public sealed class InspectorViewModel : ViewModelBase
}
}
+ // 속성 추가는 단일 선택만. 중복 검사·쓰기·제안이 전부 대상 하나 기준이라
+ // 다중 선택에서 누르면 N개 중 1개에만 키가 생기고, 나머지는 아무 표시 없이 그대로다.
+ if (!single)
+ {
+ return;
+ }
+
// 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다).
// 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 —
// 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다.
@@ -752,7 +845,16 @@ public sealed class InspectorViewModel : ViewModelBase
}, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys)));
}
- private void AddDefRow(PropertyDef def, string controlType)
+ ///
+ /// 컨트롤 모델 → 직계 부모 모델(최상위면 null).
+ ///
+ /// 라디오 배타 판정에 쓴다 — 레거시 라디오는 GroupName 이 없어 직계 부모가 곧 문항이다.
+ /// 선택 목록을 뒤져 같은 모델의 VM 을 찾고 그 Parent 를 돌려준다.
+ ///
+ private ControlElement? ParentModelOf(ControlElement model)
+ => designer.Selection.Items.FirstOrDefault(vm => ReferenceEquals(vm.Model, model))?.Parent?.Model;
+
+ private void AddDefRow(PropertyDef def, string controlType, IReadOnlyList targets)
{
var binding = def.Editor == PropEditorKind.StringList
? new RowBinding
@@ -769,6 +871,7 @@ public sealed class InspectorViewModel : ViewModelBase
}
vm.Model.Props.Set(def.Key, itemsValue);
},
+ ConfirmBatch = ConfirmDestructive(def.Key),
}
: new RowBinding
{
@@ -793,6 +896,7 @@ public sealed class InspectorViewModel : ViewModelBase
vm.Model.Props.SetText(def.Key, value);
}
},
+ ConfirmBatch = ConfirmDestructive(def.Key),
};
PropertyRowViewModel row = def.Editor switch
@@ -818,9 +922,25 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames),
_ => new TextRowViewModel(def.Label),
};
- AddRow(row, binding);
+ // 선의 방향·굵기는 값만 바꾸는 것이 아니라 크기까지 바꾼다(ApplyLineChange).
+ // 알리지 않으면 선택 핸들이 옛 사각형에 남아, 화면에서는 선이 돌았는데 테두리는 그대로다.
+ AddRow(row, binding, def.Key is "Orientation" or "BorderWidth"
+ ? () => designer.Selection.NotifyBoundsChanged()
+ : null);
}
+ ///
+ /// 여러 대상의 고유값을 한꺼번에 덮는 편집에만 붙이는 확인 — 텍스트·항목 목록.
+ ///
+ /// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다. 확인을 남발하면 아무도 읽지 않고,
+ /// 정작 위험한 것을 물었을 때도 습관적으로 넘긴다.
+ ///
+ private static Func? ConfirmDestructive(string key) => key is "Text" or "Items"
+ ? count => Services.DialogService.Confirm("일괄 변경",
+ $"{count}개 컨트롤의 기존 값이 모두 같은 값으로 바뀝니다.",
+ "각자 다르게 들어 있던 값은 되살릴 수 없습니다(되돌리기로만 복구됩니다).")
+ : null;
+
///
/// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
///
@@ -905,6 +1025,8 @@ public sealed class InspectorViewModel : ViewModelBase
set(vm, Math.Round(number));
}
},
+ // 숫자가 아니면 커밋 자체를 막는다 — 전에는 스냅샷만 쌓이고 값은 하나도 안 바뀌었다
+ Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
AffectsVisual = false,
};
BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
@@ -941,6 +1063,10 @@ public sealed class InspectorViewModel : ViewModelBase
set(font, value);
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
},
+ // 글꼴 크기는 양수만 — 0 이나 음수를 넣으면 Set 이 조용히 옛 크기를 유지해
+ // "바꿨는데 안 바뀐다"가 된다. 커밋 전에 막고 칸을 되돌린다.
+ Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var size)
+ && size > 0,
});
}
@@ -1030,6 +1156,12 @@ public sealed class InspectorViewModel : ViewModelBase
// 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다.
if (row is ChoiceRowViewModel choice)
{
+ // 값이 갈리면 '여러 값' 자리를 먼저 만든다 — 그러면 빈 칸이 '값 없음'과 구별되고,
+ // 목록에 없는 값(빈 문자열)을 SelectedItem 에 물리는 상태 자체가 사라진다.
+ if (values.Count > 1)
+ {
+ choice.EnsureMixedPlaceholder();
+ }
choice.EnsureChoice(initial);
}
@@ -1042,6 +1174,27 @@ public sealed class InspectorViewModel : ViewModelBase
{
return;
}
+ // ── 스냅샷 앞의 관문 셋. 순서가 중요하다: 여기서 걸러야 '바뀐 것이 없는데
+ // 문서가 수정됨' 이 생기지 않고, 그래야 사용자가 그걸 지우려 Ctrl+Z 를 누를 일도 없다.
+ if (binding.Validate is { } validate && !validate(value))
+ {
+ // 값으로 인정하지 않았으므로 칸을 원래 표시로 되돌린다 — 잘못된 문자열이
+ // 다음 재구성까지 남아 값인 척하는 것을 막는다
+ var current = designer.Selection.Items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
+ row.Initialize(current.Count == 1 ? current[0] : null, isMixed: current.Count > 1);
+ return;
+ }
+ // 이미 전부 그 값이면 아무 일도 하지 않는다(스냅샷도 찍지 않는다)
+ var before = targets.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
+ if (before.Count == 1 && string.Equals(before[0], value, StringComparison.Ordinal))
+ {
+ return;
+ }
+ if (targets.Count > 1 && binding.ConfirmBatch is { } confirm && !confirm(targets.Count))
+ {
+ row.Initialize(before.Count == 1 ? before[0] : null, isMixed: before.Count > 1);
+ return;
+ }
// 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라
// 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다.
if (row.CoalesceUndo)
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
index a977766..20b4d6c 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
@@ -30,6 +30,15 @@ public abstract class PropertyRowViewModel : ViewModelBase
/// 선택 대상들의 값이 서로 다른지 — "여러 값" 표시
public bool IsMixed { get; private set; }
+ ///
+ /// 값이 갈렸을 때 요약 칸에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용.
+ ///
+ /// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 단정을 그린다.
+ /// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 —
+ /// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다.
+ ///
+ protected const string MixedSummary = "(여러 값)";
+
///
/// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용.
/// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼
@@ -52,6 +61,11 @@ public abstract class PropertyRowViewModel : ViewModelBase
get => valueText;
set
{
+ // null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고
+ // (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다)
+ // 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다.
+ // 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다.
+ value ??= string.Empty;
if (!SetProperty(ref valueText, value) || building)
{
return;
@@ -365,10 +379,21 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
get => IsMixed ? null : ValueText == "True";
set
{
- if (value is bool on)
+ if (value is not bool on)
{
- ValueText = on ? "True" : "False";
+ return;
}
+ // 갈린 상태에서 누르면 켠다. WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면
+ // IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false).
+ // 그대로 두면 굵게를 켜려고 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다.
+ // 실측(edit-smoke): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False.
+ // 갈린 상태에서 setter 에 false 가 오는 경로는 이 클릭뿐이므로(사용자가 '꺼짐'을 본 적이
+ // 없다) 켜는 것으로 읽는다 — 디자이너 관례도 '혼합을 누르면 전부 켜기'다.
+ if (IsMixed && !on)
+ {
+ on = true;
+ }
+ ValueText = on ? "True" : "False";
}
}
@@ -402,6 +427,27 @@ public sealed class ChoiceRowViewModel : PropertyRowViewModel
choices.Add(value);
}
}
+
+ ///
+ /// 값이 갈린 다중 선택에서 '여러 값' 자리를 목록 맨 앞에 만든다(빈 문자열).
+ ///
+ /// 두 가지를 동시에 해결한다.
+ /// ① 보이게 한다. 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데,
+ /// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다.
+ /// ② 코어스를 막는다. 는 IsNullOrEmpty 에서 빠져나가므로
+ /// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가
+ /// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지
+ /// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다.
+ ///
+ /// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다.
+ ///
+ public void EnsureMixedPlaceholder()
+ {
+ if (!choices.Contains(string.Empty, StringComparer.Ordinal))
+ {
+ choices.Insert(0, string.Empty);
+ }
+ }
}
///
@@ -613,7 +659,9 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
public sealed class MaskRowViewModel : PropertyRowViewModel
{
/// 이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인
- public string Preview => ValueText.Length == 0
+ public string Preview => IsMixed
+ ? MixedSummary
+ : ValueText.Length == 0
? "(마스크 없음)"
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
@@ -648,6 +696,10 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
{
get
{
+ if (IsMixed)
+ {
+ return MixedSummary;
+ }
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
}
@@ -843,6 +895,10 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{
get
{
+ if (IsMixed)
+ {
+ return MixedSummary;
+ }
if (ValueText.Length == 0)
{
return "(배선 없음)";
@@ -987,4 +1043,22 @@ public sealed class RowBinding
/// 커밋 후 시각 재해석 필요 여부
public bool AffectsVisual { get; init; } = true;
+
+ ///
+ /// 커밋 전 값 검사 — false 면 아무것도 하지 않고 칸을 원래 표시로 되돌린다.
+ ///
+ /// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다
+ /// TryParse 에 실패해 아무것도 안 바뀌었다 — 바뀐 것이 없는데 문서는 '수정됨'이 되고
+ /// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가
+ /// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다.
+ ///
+ public Func? Validate { get; init; }
+
+ ///
+ /// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수).
+ ///
+ /// 대상마다 서로 다른 고유값이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록).
+ /// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다.
+ ///
+ public Func? ConfirmBatch { get; init; }
}
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index 1a17713..1cbb3bb 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -11,6 +11,7 @@
+
+
+
+
+
+
@@ -425,6 +438,15 @@
+
+
@@ -533,7 +555,28 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SheetMe.Core.Tests/BatchEditPolicyTests.cs b/tests/SheetMe.Core.Tests/BatchEditPolicyTests.cs
new file mode 100644
index 0000000..1c37169
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/BatchEditPolicyTests.cs
@@ -0,0 +1,237 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SheetMe.Core.Catalog;
+using SheetMe.Core.Models;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// 여러 컨트롤을 함께 고쳤을 때 레거시가 못 여는 XML이 나오지 않는지 고정한다.
+///
+/// 여기서 막는 것들은 전부 화면으로 확인할 수 없는 손상이다 —
+/// 디자이너는 멀쩡해 보이고, EMR 에서만 다르게 나오거나 서식이 아예 안 열린다.
+///
+[TestClass]
+public class BatchEditPolicyTests
+{
+ #region Helpers
+ private static ControlElement Element(string type, string id = "c")
+ => new() { Id = id, Type = type };
+
+ private static IReadOnlyList KeysOf(IReadOnlyList types)
+ => BatchEditPolicy.MergeCommonDefs(types).Select(d => d.Key).ToList();
+ #endregion
+
+ #region Tests - 공통 속성 병합
+ [TestMethod]
+ public void 타입이_하나면_그_타입의_속성을_그대로_준다()
+ {
+ var label = ControlRegistry.Find("Label");
+ Assert.IsNotNull(label);
+ CollectionAssert.AreEqual(
+ label.Properties.Select(d => d.Key).ToList(),
+ KeysOf(new[] { "Label" }).ToList());
+ }
+
+ [TestMethod]
+ public void 정렬은_선택지가_달라_공통에_오지_않는다()
+ {
+ // 라벨은 ContentAlignment 9값(TopLeft…), 텍스트박스는 HorizontalAlignment 3값(Left/Center/Right).
+ // 키 이름만 비교하는 구현으로 되돌아가면 "MiddleCenter" 가 텍스트박스에 들어가고,
+ // 그 값은 레거시가 읽지 못한다.
+ CollectionAssert.DoesNotContain(KeysOf(new[] { "Label", "TextBox" }).ToList(), "TextAlign");
+ }
+
+ [TestMethod]
+ public void 표시_키는_대소문자로_갈린다()
+ {
+ // 라벨만 소문자 visible 이다(레거시가 그림자 속성을 쓴다). 별칭으로 묶으면
+ // 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
+ var mixed = KeysOf(new[] { "Label", "TextBox" }).ToList();
+ CollectionAssert.DoesNotContain(mixed, "visible");
+ CollectionAssert.DoesNotContain(mixed, "Visible");
+ }
+
+ [TestMethod]
+ public void 스키마를_모르는_타입이_섞이면_공통이_없다()
+ {
+ // 무엇이 공통인지 말할 근거가 없다 — 추측해서 열면 그게 손상 경로가 된다
+ Assert.AreEqual(0, KeysOf(new[] { "Label", "Spread" }).Count);
+ }
+
+ [TestMethod]
+ public void 같은_정의를_가진_공통_속성은_병합된다()
+ {
+ // 공통 7종은 `=> new()` 식 본문 프로퍼티라 접근할 때마다 새 인스턴스다.
+ // def 참조를 비교하는 구현이면 전부 '다르다'가 나와 이 검사가 깨진다.
+ CollectionAssert.Contains(KeysOf(new[] { "Label", "TextBox" }).ToList(), "PrintOutPut");
+ }
+
+ [TestMethod]
+ public void 선택이_없으면_빈_집합이다()
+ {
+ Assert.AreEqual(0, BatchEditPolicy.MergeCommonDefs(Array.Empty()).Count);
+ }
+
+ [TestMethod]
+ public void 공통에_못_올린_것의_개수를_센다()
+ {
+ // 조용히 빼면 사용자는 그 속성이 존재하지 않는다고 읽는다 — 개수를 알려 줘야 한다
+ Assert.IsTrue(BatchEditPolicy.ExcludedCount(new[] { "Label", "TextBox" }) > 0);
+ Assert.AreEqual(0, BatchEditPolicy.ExcludedCount(new[] { "Label" }));
+ }
+ #endregion
+
+ #region Tests - 차단
+ [TestMethod]
+ public void 대상이_하나면_아무것도_막지_않는다()
+ {
+ // 이 기능이 기존 단일 선택 동작을 좁히면 안 된다
+ var one = new[] { Element("Label") };
+ foreach (var key in new[] { "Name", "SignatureIndex", "Formula", "SequenceCode", "TextAlign" })
+ {
+ Assert.IsTrue(BatchEditPolicy.IsBatchable(key, one).Allowed, key);
+ }
+ }
+
+ [TestMethod]
+ public void 유일해야_하는_키는_함께_못_바꾼다()
+ {
+ var two = new[] { Element("Label", "a"), Element("Label", "b") };
+ foreach (var key in new[] { "Name", "SignatureIndex", "DisplaySequence", "TabIndex", "LinkToNewDesign" })
+ {
+ Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
+ }
+ }
+
+ [TestMethod]
+ public void 남을_이름으로_가리키는_배선은_함께_못_바꾼다()
+ {
+ var two = new[] { Element("RadioButton", "a"), Element("RadioButton", "b") };
+ foreach (var key in new[] { "Formula", "DataActionTagControl", "AssociatedControl", "EventHandlerMappingTag" })
+ {
+ Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
+ }
+ // 액션 태그 자체는 정상 작업이다 — 같은 문항의 라디오에 같은 액션이 붙는다
+ Assert.IsTrue(BatchEditPolicy.IsBatchable("DataActionTag", two).Allowed);
+ }
+
+ [TestMethod]
+ public void 일련번호_발급은_함께_못_바꾼다()
+ {
+ // 서식을 여는 것만으로 DB 카운터가 오른다 — Undo 로 덮이지 않는 유일한 부류다
+ var two = new[] { Element("Label", "a"), Element("Label", "b") };
+ foreach (var key in new[] { "SequenceCode", "SequenceType", "SequenceNumber", "CheckSequenceAtSaving" })
+ {
+ Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
+ }
+ }
+
+ [TestMethod]
+ public void 정렬은_타입이_섞였을_때만_막는다()
+ {
+ var sameType = new[] { Element("TextBox", "a"), Element("TextBox", "b") };
+ var mixedType = new[] { Element("Label", "a"), Element("TextBox", "b") };
+
+ Assert.IsTrue(BatchEditPolicy.IsBatchable("TextAlign", sameType).Allowed);
+ Assert.IsFalse(BatchEditPolicy.IsBatchable("TextAlign", mixedType).Allowed);
+ }
+
+ [TestMethod]
+ public void 선_방향은_원문_경로에서만_막는다()
+ {
+ // 전용 편집기는 컨트롤마다 크기까지 함께 고치므로(ApplyLineChange) 다중 선택에서도 옳다.
+ // 원문 텍스트 행은 속성만 써서 XML 은 Vertical 인데 경계는 가로인 선을 만든다.
+ var lines = new[] { Element("Line", "a"), Element("Line", "b") };
+ Assert.IsTrue(BatchEditPolicy.IsBatchable("Orientation", lines).Allowed);
+ Assert.IsFalse(BatchEditPolicy.IsBatchable("Orientation", lines, viaAdvancedRaw: true).Allowed);
+ Assert.IsFalse(BatchEditPolicy.IsBatchable("BorderWidth", lines, viaAdvancedRaw: true).Allowed);
+ }
+
+ [TestMethod]
+ public void 같은_묶음의_라디오는_기본_선택을_함께_못_바꾼다()
+ {
+ // 레거시 라디오는 GroupName 이 없어 직계 부모가 문항이다. 둘 다 켜면 한 문항의 답이 둘이 되고,
+ // 로더가 부모에 붙이기 전에 값을 대입하므로 열 때 정리되지도 않는다.
+ var parent = Element("Panel", "p");
+ var first = Element("RadioButton", "r1");
+ var second = Element("RadioButton", "r2");
+ var targets = new[] { first, second };
+
+ Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
+ }
+
+ [TestMethod]
+ public void 부모가_다른_라디오는_함께_바꿀_수_있다()
+ {
+ var left = Element("Panel", "p1");
+ var right = Element("Panel", "p2");
+ var first = Element("RadioButton", "r1");
+ var second = Element("RadioButton", "r2");
+
+ var allowed = BatchEditPolicy.IsBatchable("Checked", new[] { first, second },
+ parentOf: element => ReferenceEquals(element, first) ? left : right).Allowed;
+
+ Assert.IsTrue(allowed);
+ }
+
+ [TestMethod]
+ public void 체크박스는_배타가_없어_함께_바꿀_수_있다()
+ {
+ var parent = Element("Panel", "p");
+ var targets = new[] { Element("CheckBox", "c1"), Element("CheckBox", "c2") };
+
+ Assert.IsTrue(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
+ }
+
+ [TestMethod]
+ public void 부모를_모르면_라디오를_막는다()
+ {
+ // 모르면 막는 쪽이다 — 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고
+ // 그것은 화면으로 확인할 수 없다
+ var targets = new[] { Element("RadioButton", "r1"), Element("RadioButton", "r2") };
+
+ Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets).Allowed);
+ }
+ #endregion
+
+ #region Tests - 고급(원문) 경로
+ [TestMethod]
+ public void 전부_갖고_전부_문자열일_때만_원문을_연다()
+ {
+ var first = Element("Label", "a");
+ var second = Element("Label", "b");
+ first.Props.SetText("Tooltip", "가");
+ second.Props.SetText("Tooltip", "나");
+
+ Assert.IsTrue(BatchEditPolicy.AllShareTextShape("Tooltip", new[] { first, second }));
+ }
+
+ [TestMethod]
+ public void 값의_형태가_다르면_원문을_열지_않는다()
+ {
+ // 이미지 원문(BinaryValue)을 문자열로 덮으면 복구할 수 없다.
+ // GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지가 둘 다 "" 로 접혀
+ // 갈렸다는 표시조차 안 나온다 — 그래서 형태로 판정한다.
+ var text = Element("PictureBox", "a");
+ var binary = Element("PictureBox", "b");
+ text.Props.SetText("Image", string.Empty);
+ binary.Props.Set("Image", new LegacyPropValue.BinaryValue { Base64 = "AQID" });
+
+ // 어느 쪽을 먼저 골랐든 결과가 같아야 한다 — 클릭 순서로 안전성이 갈리면 안 된다
+ Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { text, binary }));
+ Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { binary, text }));
+ }
+
+ [TestMethod]
+ public void 일부만_가진_키는_원문을_열지_않는다()
+ {
+ // 없던 대상에 키가 생기면 레거시 서식생성기가 열 때마다 오류 모달을 띄운다
+ var has = Element("Label", "a");
+ var lacks = Element("Label", "b");
+ has.Props.SetText("Score", "5");
+
+ Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Score", new[] { has, lacks }));
+ Assert.AreEqual(1, BatchEditPolicy.CountHaving("Score", new[] { has, lacks }));
+ }
+ #endregion
+}