diff --git a/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs index ecc5368..f2b8280 100644 --- a/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs +++ b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs @@ -40,10 +40,15 @@ public sealed class CanvasKeyboardBehavior : Behavior var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control); var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift); + var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt); var step = shift ? designer.Snap.GridSize * 2 : 1; var handled = true; - switch (e.Key) + // Alt 조합은 e.Key 로 오지 않는다 — WPF 가 Key.System 으로 싸서 넘기고 실제 키는 SystemKey 에 있다. + // e.Key 만 보면 Alt 단축키가 하나도 안 걸리는데, 그 실패는 "눌러도 아무 일이 없다"로만 보인다. + var key = e.Key == Key.System ? e.SystemKey : e.Key; + + switch (key) { case Key.Left: designer.NudgeSelection(-step, 0); break; case Key.Right: designer.NudgeSelection(step, 0); break; @@ -67,6 +72,31 @@ public sealed class CanvasKeyboardBehavior : Behavior case Key.Y when ctrl: designer.Undo.Redo(); break; case Key.G when ctrl && shift: designer.UngroupSelection(); break; case Key.G when ctrl: designer.GroupSelection(); break; + + // 배치 명령 — 전에는 단축키가 0개였고 전부 로고 아래 4단 메뉴 안에만 있었다. + // 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다. + // 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다. + case Key.A when alt: designer.Align("Left"); break; + case Key.D when alt: designer.Align("Right"); break; + case Key.W when alt && !shift: designer.Align("Top"); break; + case Key.S when alt && !shift: designer.Align("Bottom"); break; + case Key.H when alt && !shift: designer.Align("CenterH"); break; + case Key.V when alt && !shift: designer.Align("CenterV"); break; + + // 균등 간격 — 3개 이상일 때만 뜻이 있다 + case Key.H when alt && shift: designer.AdjustSpacing("HorizEqual"); break; + case Key.V when alt && shift: designer.AdjustSpacing("VertEqual"); break; + + // 같은 크기로 + case Key.W when alt && shift: designer.SizeToControl("Width"); break; + case Key.S when alt && shift: designer.SizeToControl("Height"); break; + + // 간격 넓게/좁게 — 세로가 기본(기록지는 줄이 세로로 쌓인다), Shift 로 가로 + case Key.OemCloseBrackets when alt && !shift: designer.AdjustSpacing("VertIncrease"); break; + case Key.OemOpenBrackets when alt && !shift: designer.AdjustSpacing("VertDecrease"); break; + case Key.OemCloseBrackets when alt && shift: designer.AdjustSpacing("HorizIncrease"); break; + case Key.OemOpenBrackets when alt && shift: designer.AdjustSpacing("HorizDecrease"); break; + default: handled = false; break; } diff --git a/src/SheetMe.Designer/Diagnostics/ModalCheck.cs b/src/SheetMe.Designer/Diagnostics/ModalCheck.cs index 320eaa1..cbac7c9 100644 --- a/src/SheetMe.Designer/Diagnostics/ModalCheck.cs +++ b/src/SheetMe.Designer/Diagnostics/ModalCheck.cs @@ -69,6 +69,8 @@ public static class ModalCheck ShowInTaskbar = false, WindowStartupLocation = WindowStartupLocation.CenterOwner, }; + // 자격을 밝힌 창만 어두워진다 — 아래 ⑤ 가 밝히지 않은 창을 따로 확인한다 + Services.ModalScrim.SetDimBehind(dialog, true); var scrimsWhileOpen = -1; var dialogAbove = false; @@ -103,6 +105,32 @@ public static class ModalCheck Check("가림막이 대화상자 아래에 있다", dialogAbove); Check("닫으면 가림막이 걷힌다", CountScrims() == 0, $"실제 {CountScrims()}개"); + // ⑤ 자격을 밝히지 않은 창은 어두워지지 않는다. + // + // 이것이 이 검사에서 가장 중요한 항목이다. 전에는 모든 ShowDialog 가 무조건 어두워져서 + // 태그 하나 고르는 데도 앱이 암전했다 — 하루 수백 번이면 신호가 아니라 소음이다. + // 위 ①~④ 만 있으면 "깔린다"는 확인되지만 "안 깔려야 할 때 안 깔린다"는 확인되지 않는다. + var plainScrims = -1; + var plain = new Window + { + Title = "가림막 검사 — 자격 없는 대화상자", + Width = 300, Height = 200, Owner = owner, + ShowInTaskbar = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + }; + var plainTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) }; + plainTimer.Tick += (_, _) => + { + plainTimer.Stop(); + plainScrims = CountScrims(); + plain.Close(); + }; + plainTimer.Start(); + plain.ShowDialog(); + + lines.Add($"자격 없는 대화상자가 떠 있는 동안의 가림막 수 = {plainScrims}"); + Check("자격을 밝히지 않으면 어두워지지 않는다", plainScrims == 0, $"실제 {plainScrims}개"); + owner.Close(); } catch (Exception ex) diff --git a/src/SheetMe.Designer/Services/ModalScrim.cs b/src/SheetMe.Designer/Services/ModalScrim.cs index 4d62849..6972b9e 100644 --- a/src/SheetMe.Designer/Services/ModalScrim.cs +++ b/src/SheetMe.Designer/Services/ModalScrim.cs @@ -30,6 +30,31 @@ public static class ModalScrim /// /// 이 창이 모달이면 뒤에 가림막을 깐다 — 창이 보이기 시작할 때 불러야 한다. /// + /// + /// 이 창이 뒤를 어둡게 할 자격이 있는가 — 기본값은 아니다. + /// + /// 전에는 모든 ShowDialog 에 무조건 붙었다. 그래서 태그 하나 고르는 데도 앱이 암전했고, + /// 하루 수백 번 그러면 그건 신호가 아니라 소음이다. + /// + /// 암전이 옳은 자리는 지금 멈추고 결정해야 하는 곳뿐이다 — + /// 되돌릴 수 없는 쓰기의 확인(운영 EMR 테이블에 버전을 만든다), 진행이 정의되지 않는 차단 통보. + /// 값 하나를 고르는 일은 Undo 로 되돌아가고 판단 근거가 뒤 화면에 있으므로 가리면 안 된다. + /// + /// 전역 후크는 그대로 둔다 — 호출부 17곳 중 하나만 빠져도 그 대화상자만 다르게 동작하고, + /// 그건 눈으로 전수 확인해야만 알 수 있다. 대신 기본을 뒤집었다: + /// 후크는 항상 돌지만, 창이 스스로 자격을 밝혀야 깔린다. + /// 새 대화상자가 이것을 잊으면 암전이 안 되는데, 이제 그쪽이 안전한 기본값이다. + /// + public static readonly DependencyProperty DimBehindProperty = + DependencyProperty.RegisterAttached("DimBehind", typeof(bool), typeof(ModalScrim), + new PropertyMetadata(false)); + + public static void SetDimBehind(DependencyObject target, bool value) + => target.SetValue(DimBehindProperty, value); + + public static bool GetDimBehind(DependencyObject target) + => (bool)target.GetValue(DimBehindProperty); + public static void Attach(Window window) { // 모달 판정을 여기서 바로 하면 안 된다. ShowDialog() 는 창을 먼저 보이고(그때 Loaded 가 온다) @@ -42,8 +67,10 @@ public static class ModalScrim private static void AttachIfModal(Window window) { - // 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다 - if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible) + // 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다. + // 그리고 스스로 자격을 밝힌 창만 깐다 — 값을 고르는 대화상자는 뒤를 가리면 안 된다. + if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible + || !GetDimBehind(window)) { return; } diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs index ec70ab7..b8f86da 100644 --- a/src/SheetMe.Designer/ViewModels/MainViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs @@ -189,7 +189,19 @@ internal sealed class MainViewModel : ViewModelBase LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync()); NewFileCommand = new Command((sender, e) => OnNewFile()); OpenFileCommand = new Command((sender, e) => OnOpenFile()); - SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false)); + // Ctrl+S 가 이 커맨드에 걸려 있다. 문서 출처로 갈라야 한다 — + // DB 에서 연 서식에서 Ctrl+S 를 누르면 OS 저장 대화상자가 뜨고 디스크에 XML 이 쓰였다. + // 사용자는 저장했다고 믿지만 DB 에는 아무것도 안 들어간다. + // 종료 가드는 이미 IsFromDb 로 갈라 준다(:365) — Ctrl+S 만 안 갈라 주고 있었다. + SaveFileCommand = new Command((sender, e) => + { + if (CurrentDesigner?.IsFromDb == true) + { + OnSaveToDb(); + return; + } + OnSaveFile(saveAs: false); + }); SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true)); OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb()); SaveToDbCommand = new Command((sender, e) => OnSaveToDb()); @@ -697,9 +709,12 @@ internal sealed class MainViewModel : ViewModelBase $"서식 [{document.FormId}] 이(가) 다른 탭에도 열려 있습니다.", "그 탭은 방금 저장하기 전의 내용입니다. 거기서 저장하면 이번 저장이 덮어써집니다 — 닫고 다시 여세요."); } + // 완료 알림 모달을 띄우지 않는다. 정보량이 0 이었다 — SdgKey 는 바로 위에서 상태바에 넣고, + // 같은 값을 모달로 한 번 더 보여 주는 것이었다. + // 하루 30회 저장하는 사람에게 그건 Enter 를 30번 치는 의식이고, + // 그때마다 화면 전체가 어두워졌다(스크림). 실패는 계속 모달로 알린다 — + // 그건 진행이 정의되지 않는 상태이고, 조용히 넘기면 저장된 줄 알고 넘어간다. StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}"; - DialogService.Notify(DialogKind.Info, "DB 저장", - $"저장되었습니다. (SdgKey {sdgKey})", "레거시 뷰어/디자이너에서 열어 확인하세요."); return true; } catch (Exception ex) diff --git a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml index ab23ae2..6f23e0d 100644 --- a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml +++ b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml @@ -17,6 +17,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +