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 @@ -