diff --git a/src/SheetMe.Designer/Behaviors/InspectorFieldBehavior.cs b/src/SheetMe.Designer/Behaviors/InspectorFieldBehavior.cs
new file mode 100644
index 0000000..4c05725
--- /dev/null
+++ b/src/SheetMe.Designer/Behaviors/InspectorFieldBehavior.cs
@@ -0,0 +1,164 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Input;
+
+namespace SheetMe.Designer.Behaviors;
+
+///
+/// 인스펙터 입력 칸의 키보드 조작 — 피그마식 사용감의 핵심이다.
+///
+/// 종전에는 모든 칸이 UpdateSourceTrigger=LostFocus 하나였다. 값을 고치고 나서
+/// **다른 곳을 눌러야** 반영되니, 캔버스를 보며 숫자를 맞춰 가는 작업이 성립하지 않았다.
+///
+/// 붙이는 동작 셋:
+/// · Enter — 즉시 확정하고 전체 선택(확정됐다는 신호 + 이어서 덮어 치기 쉽다).
+/// 여러 줄 입력에서는 줄바꿈이어야 하므로 건드리지 않는다.
+/// · Esc — 소스에서 화면을 다시 읽어 되돌린다. 커밋이 없었으므로 Undo 도 남지 않는다.
+/// · ↑/↓ — 숫자 칸에서 증감하고 곧바로 확정한다. 증분은 칸마다 지정한다.
+///
+/// UpdateSourceTrigger 는 절대 PropertyChanged 로 바꾸지 말 것. 커밋 한 번이 문서 전체
+/// 딥클론 1회(Undo 스냅샷)라 한 글자마다 스냅샷이 쌓이고, 경계 행은 커밋 결과가 정규화 값으로
+/// 되쓰여 캐럿이 튄다. 피그마식 사용감은 트리거가 아니라 명시적 확정 제스처로 얻는다.
+///
+public static class InspectorFieldBehavior
+{
+ #region Attached Properties
+ /// Enter 확정 · Esc 되돌리기를 붙인다
+ public static readonly DependencyProperty CommitOnEnterProperty =
+ DependencyProperty.RegisterAttached(
+ "CommitOnEnter", typeof(bool), typeof(InspectorFieldBehavior),
+ new PropertyMetadata(false, OnCommitOnEnterChanged));
+
+ public static void SetCommitOnEnter(DependencyObject element, bool value)
+ => element.SetValue(CommitOnEnterProperty, value);
+
+ public static bool GetCommitOnEnter(DependencyObject element)
+ => (bool)element.GetValue(CommitOnEnterProperty);
+
+ /// ↑/↓ 로 숫자를 증감한다(숫자 칸에만 붙일 것)
+ public static readonly DependencyProperty SpinProperty =
+ DependencyProperty.RegisterAttached(
+ "Spin", typeof(bool), typeof(InspectorFieldBehavior),
+ new PropertyMetadata(false, OnSpinChanged));
+
+ public static void SetSpin(DependencyObject element, bool value)
+ => element.SetValue(SpinProperty, value);
+
+ public static bool GetSpin(DependencyObject element)
+ => (bool)element.GetValue(SpinProperty);
+
+ ///
+ /// Ctrl 미세 증분. 0 이면 미세조정 없음이 기본이다 — X/Y/너비/높이는 읽기·쓰기 양쪽에서
+ /// 반올림되므로 0.1 을 더해도 모델은 그대로인데 문자열만 바뀌어, 화면은 그대로면서
+ /// Undo 스냅샷과 '수정됨' 표시만 남는다. 소수를 실제로 받는 칸(글꼴 크기)에서만 켠다.
+ ///
+ public static readonly DependencyProperty FineStepProperty =
+ DependencyProperty.RegisterAttached(
+ "FineStep", typeof(double), typeof(InspectorFieldBehavior), new PropertyMetadata(0.0));
+
+ public static void SetFineStep(DependencyObject element, double value)
+ => element.SetValue(FineStepProperty, value);
+
+ public static double GetFineStep(DependencyObject element)
+ => (double)element.GetValue(FineStepProperty);
+ #endregion
+
+ #region Methods
+ private static void OnCommitOnEnterChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not TextBox box)
+ {
+ return;
+ }
+ box.PreviewKeyDown -= OnKeyDown;
+ if (e.NewValue is true)
+ {
+ box.PreviewKeyDown += OnKeyDown;
+ }
+ }
+
+ private static void OnSpinChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not TextBox box)
+ {
+ return;
+ }
+ box.PreviewKeyDown -= OnSpinKeyDown;
+ if (e.NewValue is true)
+ {
+ box.PreviewKeyDown += OnSpinKeyDown;
+ }
+ }
+
+ private static void OnKeyDown(object sender, KeyEventArgs e)
+ {
+ if (sender is not TextBox box)
+ {
+ return;
+ }
+
+ // 여러 줄 입력에서 Enter 는 줄바꿈이다 — 확정으로 가로채면 본문을 못 쓴다
+ if (e.Key == Key.Enter && !box.AcceptsReturn)
+ {
+ Commit(box);
+ // 확정 결과(정규화된 값)를 선택 상태로 보여준다 — 확정 신호이자 곧바로 덮어 칠 수 있는 상태
+ box.SelectAll();
+ e.Handled = true;
+ return;
+ }
+
+ if (e.Key == Key.Escape)
+ {
+ // 편집 전 값을 따로 보관하지 않는다. LostFocus 트리거라 소스는 아직 손대지 않은 상태이고,
+ // 소스가 곧 백업이다. 문자열을 기억해 두면 그 사이 캔버스 드래그로 소스가 바뀌었을 때
+ // 낡은 값을 되살려 컨트롤을 원위치로 되돌리는 사고가 난다.
+ BindingOperations.GetBindingExpression(box, TextBox.TextProperty)?.UpdateTarget();
+ box.SelectAll();
+ e.Handled = true;
+ }
+ }
+
+ private static void OnSpinKeyDown(object sender, KeyEventArgs e)
+ {
+ if (sender is not TextBox box || (e.Key != Key.Up && e.Key != Key.Down))
+ {
+ return;
+ }
+ // 숫자가 아니면 캐럿 이동 기본 동작을 살려 둔다.
+ // 다중선택으로 값이 갈린 칸은 "" 이라 여기서 걸리는데 **이것이 의도된 방어**다 —
+ // 0 으로 간주해 증감하면 서로 다르던 값이 한 값으로 뭉개진다.
+ // 커밋 경로가 전부 invariant 이므로 파싱도 invariant 여야 왕복이 안 깨진다.
+ if (!double.TryParse(box.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+ {
+ return;
+ }
+
+ var fine = GetFineStep(box);
+ double step;
+ if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && fine > 0)
+ {
+ step = fine;
+ }
+ else if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
+ {
+ step = 10;
+ }
+ else
+ {
+ step = 1;
+ }
+
+ value += e.Key == Key.Up ? step : -step;
+ box.Text = value.ToString("0.##", CultureInfo.InvariantCulture);
+ box.CaretIndex = box.Text.Length;
+ Commit(box);
+ e.Handled = true;
+ }
+
+ /// 바인딩 소스에 지금 값을 밀어 넣는다(LostFocus 를 기다리지 않는다)
+ private static void Commit(TextBox box)
+ => BindingOperations.GetBindingExpression(box, TextBox.TextProperty)?.UpdateSource();
+ #endregion
+}
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
index 217732d..39736d7 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
@@ -196,7 +196,7 @@ public sealed class InspectorViewModel : ViewModelBase
// 폰트(공통) — Font Property(상속 시 빈 값)
AddSection("글꼴");
AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v);
- AddFontRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
+ AddFontNumberRow("크기(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);
AddFontStyleSegment();
}
@@ -391,6 +391,9 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.MultilineText or PropEditorKind.StringList => new MultilineTextRowViewModel(def.Label),
PropEditorKind.Number => new NumberRowViewModel(def.Label),
PropEditorKind.Toggle => new ToggleRowViewModel(def.Label),
+ // 정렬은 격자로 — 레거시 원문 열거값("MiddleCenter")을 드롭다운에 그대로 두면 뜻이 안 보인다
+ PropEditorKind.Choice when def.Key == "TextAlign"
+ => new AlignRowViewModel(def.Label, def.Choices ?? Array.Empty()),
PropEditorKind.Choice => new ChoiceRowViewModel(def.Label, def.Choices ?? Array.Empty()),
PropEditorKind.Color => new ColorRowViewModel(def.Label),
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
@@ -422,7 +425,7 @@ public sealed class InspectorViewModel : ViewModelBase
private NumberRowViewModel CreateBoundsRow(string label, Func get, Action set)
{
- var row = new NumberRowViewModel(label);
+ var row = new NumberRowViewModel(label) { CoalesceUndo = true };
var binding = new RowBinding
{
Get = vm => Math.Round(get(vm)).ToString(CultureInfo.InvariantCulture),
@@ -454,6 +457,24 @@ public sealed class InspectorViewModel : ViewModelBase
});
}
+ ///
+ /// 숫자로 다루는 글꼴 값(크기 pt) — 화살표 증감이 붙도록 NumberRow 로 만든다.
+ /// 종전에는 TextRow 라, 정작 가장 미세조정하고 싶은 칸이 스핀 대상에서 빠져 있었다.
+ ///
+ private void AddFontNumberRow(string label, Func get, Action set)
+ {
+ AddRow(new NumberRowViewModel(label) { CoalesceUndo = true }, 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));
+ },
+ });
+ }
+
///
/// 글꼴 스타일 세그먼트 — 굵게·기울임·밑줄을 한 줄에.
/// 체크박스 하나에 한 줄씩이면 세 줄(약 100px)이고, 기울임은 아예 빠져 있었다.
@@ -552,7 +573,16 @@ public sealed class InspectorViewModel : ViewModelBase
{
return;
}
- designer.Undo.Snapshot();
+ // 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라
+ // 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다.
+ if (row.CoalesceUndo)
+ {
+ designer.Undo.SnapshotForNudge();
+ }
+ else
+ {
+ designer.Undo.Snapshot();
+ }
foreach (var target in targets)
{
binding.Set(target, value);
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
index 5605a7c..b088765 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
@@ -56,6 +56,14 @@ public abstract class PropertyRowViewModel : ViewModelBase
/// 커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용)
public Action? Commit { get; set; }
+
+ ///
+ /// 연속 커밋을 Undo 한 스텝으로 묶을지 — ↑/↓ 연타용.
+ /// 커밋 한 번이 문서 전체 딥클론 1회라, ↑ 를 20번 누르면 스냅샷 20개가 쌓여
+ /// 이력 용량(100)을 절반 가까이 갉아먹는다. 값이 아니라 커밋 방식만 바꾸므로
+ /// Initialize/Commit 규약에는 영향이 없다.
+ ///
+ public bool CoalesceUndo { get; set; }
#endregion
#region Constructors
@@ -188,6 +196,104 @@ public sealed class PairRowViewModel : PropertyRowViewModel
}
}
+/// 정렬 격자 한 칸
+public sealed class AlignCell : ViewModelBase
+{
+ private readonly AlignRowViewModel owner;
+
+ /// 저장되는 값(레거시 문자열 그대로 — TopLeft, Left …)
+ public string Value { get; }
+
+ /// 칸에 그릴 표시 — 9칸은 방향 화살표, 3칸은 정렬 아이콘명
+ public string Glyph { get; }
+
+ /// Lucide 아이콘명(있으면 화살표 대신 아이콘)
+ public string Icon { get; }
+
+ /// 아이콘으로 그릴지 화살표로 그릴지
+ public bool HasIcon => Icon.Length > 0;
+
+ public bool HasGlyph => Icon.Length == 0;
+
+ /// 현재 선택된 칸인지
+ public bool IsCurrent => string.Equals(owner.ValueText, Value, StringComparison.Ordinal);
+
+ /// 값 변경 통지 — 소유 행이 호출
+ public void NotifyCurrentChanged() => OnPropertyChanged(nameof(IsCurrent));
+
+ public AlignCell(AlignRowViewModel owner, string value, string glyph, string icon)
+ {
+ this.owner = owner;
+ Value = value;
+ Glyph = glyph;
+ Icon = icon;
+ }
+}
+
+///
+/// 정렬 선택 — 콤보 대신 격자.
+///
+/// 종전에는 "MiddleCenter" 같은 레거시 원문 열거값이 드롭다운에 그대로 떴다. 무슨 뜻인지
+/// 알려면 아홉 개를 다 열어 봐야 하고, 고르고 나서도 맞게 골랐는지 글자로만 확인된다.
+/// 값 집합이 타입마다 다르므로(라벨 9값 ContentAlignment / 텍스트박스 3값) 칸 수에 따라
+/// 3열 격자가 3×3 이나 1×3 으로 알아서 접힌다.
+///
+public sealed class AlignRowViewModel : PropertyRowViewModel
+{
+ /// 격자 칸들 — Choices 순서 그대로
+ public List Cells { get; } = new();
+
+ /// 칸 클릭 — 값 지정
+ public M.Framework.WPF.ICustomCommand? SelectCommand { get; }
+
+ public AlignRowViewModel(string label, IEnumerable choices) : base(label)
+ {
+ foreach (var choice in choices)
+ {
+ Cells.Add(new AlignCell(this, choice, GlyphOf(choice), IconOf(choice)));
+ }
+ SelectCommand = new M.Framework.WPF.Command((object parameter) =>
+ {
+ if (parameter is string value)
+ {
+ ValueText = value;
+ }
+ });
+ }
+
+ /// 값이 바뀌면 어느 칸이 켜졌는지 다시 알린다
+ protected override void OnValueApplied()
+ {
+ foreach (var cell in Cells)
+ {
+ cell.NotifyCurrentChanged();
+ }
+ }
+
+ private static string IconOf(string choice) => choice switch
+ {
+ "Left" => "align-left",
+ "Center" => "align-center",
+ "Right" => "align-right",
+ _ => string.Empty,
+ };
+
+ /// ContentAlignment 9값은 방향 화살표가 가장 빨리 읽힌다
+ private static string GlyphOf(string choice) => choice switch
+ {
+ "TopLeft" => "↖",
+ "TopCenter" => "↑",
+ "TopRight" => "↗",
+ "MiddleLeft" => "←",
+ "MiddleCenter" => "•",
+ "MiddleRight" => "→",
+ "BottomLeft" => "↙",
+ "BottomCenter" => "↓",
+ "BottomRight" => "↘",
+ _ => choice,
+ };
+}
+
/// 세그먼트 한 칸 — 아이콘 토글 하나
public sealed class SegmentItem
{
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index d9ac81d..085b75c 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -5,6 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ins="clr-namespace:SheetMe.Designer.ViewModels.Inspector"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
+ xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance ins:InspectorViewModel}">
@@ -196,7 +197,7 @@
-
+
@@ -213,20 +214,76 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -253,7 +310,7 @@
-
@@ -265,7 +322,11 @@
-
+
+
@@ -299,7 +360,7 @@
Command="{Binding BrowseCommand}" ToolTip="목록에서 선택"
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
-
@@ -350,7 +411,7 @@
ToolTip="마스크 편집기 — 자주 쓰는 마스크 목록과 미리보기"
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
-
@@ -383,7 +444,7 @@
-