From fccd4411932f2e3083ccb2237c9107b921806b35 Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 20 Aug 2026 17:16:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=84=B1=201=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=E2=80=94=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20=ED=8B=80?= =?UTF-8?q?=EB=A6=AC=EA=B2=8C=20=EB=8F=99=EC=9E=91=ED=95=98=EB=8D=98=20?= =?UTF-8?q?=EB=84=B7=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용성 감사를 돌렸더니 결론이 뜻밖이었다. 없는 기능이 문제가 아니었다 — 정렬 6종·간격 4종·같은 크기·그룹·스냅 가이드·레이어 트리·일괄 편집이 이미 다 있다. 진짜 문제는 있는 것이 틀리게 동작하고, 그 실패가 화면에 아무 흔적을 남기지 않는 것이었다. 넷 다 사용자가 무엇을 잃었는지 모르는 채로 저장하는 경로다. ## ① Ctrl+Shift+Z 가 다시 실행이 아니라 되돌리기였다 CanvasKeyboardBehavior 의 `case Key.Z when ctrl` 에 shift 검사가 없었다. 바로 아래 Key.G 는 ctrl && shift 를 가르는데 Z 만 빠져 있었다. 두 번 되돌린 뒤 한 단계 물리려 Ctrl+Shift+Z 를 누르면 세 단계 전으로 간다. 방향이 반대인 줄 모르면 두세 번 더 눌러 편집분을 통째로 날리는데, 되돌리기는 언제나 성공하므로 경고가 없다. 레이어 패널 쪽도 같은 짝이라 함께 고쳤다. ## ② Ctrl+드래그 복사를 Esc 로 취소하면 사본이 남았다 이번 세션에 Ctrl+드래그 복사를 넣으면서 만든 회귀다. CancelDrag 는 좌표만 되돌리는데, 사본은 이미 문서에 들어가 있으므로 원래 자리로 되돌리면 원본과 픽셀 단위로 겹친다. 화면은 취소된 것처럼 보이고, 그대로 저장하면 종이에 같은 글자가 두 번 인쇄된다 — 증거는 "조금 굵어 보인다" 뿐이다. 복제한 드래그였으면 좌표 복원 대신 복제 자체를 물린다. 순서가 중요하다: Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이 죽은 참조가 된다 — 상태를 먼저 비우고 그다음에 되돌린다. 그리고 DiscardRedo 로 redo 이력도 버린다. Esc 는 "되돌린다"가 아니라 "없던 일로 한다"이므로, Ctrl+Y 로 되살아나면 취소가 아니다. ## ③ 우클릭이 커서 아래를 잡지 않았다 우클릭 메뉴는 20항목인데 우클릭 선택 핸들러가 저장소 전역에 0건이었다. 라벨 A 를 골라 둔 채 떨어진 체크박스 B 를 우클릭하면 메뉴는 B 위에 뜨는데 [삭제]는 A 를 지운다. 선택이 비어 있으면 항목이 전부 살아 있는 채로 아무 일도 안 한다. 윈도우의 거의 모든 프로그램이 지키는 규약이라 사용자는 메뉴가 가리키는 대상을 확인할 생각조차 하지 않는다 — 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다. 이미 선택 안에 있으면 선택을 유지한다(다중 선택 우클릭 정렬 흐름을 깨면 안 된다). e.Handled 는 두지 않아 기존 ContextMenu 가 그대로 열린다. 함정 하나: HitTestControl 은 잠금·숨김을 히트 후보에서 아예 빼므로, 잠가 둔 배경 괘선 위 우클릭이 '빈 곳'으로 판정돼 선택이 사라진다. 잠금은 편집 보호지 부재가 아니다 — AnyControlAt 을 따로 만들어 그 위에서는 선택을 지우지 않는다. ## ④ 저장 안 된 문서를 화면에서 구별할 수 없었다 UndoService.IsDirty 는 저장 지점 깊이까지 추적해 정교하게 있었는데 (되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다. 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤, 어느 것이 안 저장됐는지 알 방법이 없다 — 탭 제목이 저장 전후로 글자 하나 다르지 않다. 그래서 안전하게 전부 다시 저장하게 되는데 DB 저장은 새 버전을 만든다 — 서식마다 쓸데없는 버전이 쌓인다. 탭에 6px 점을 찍는다. 통지는 값이 바뀔 때만 울린다 — 매번 울리면 드래그 한 번에 수백 번이다. ## 판정 - 단위 시험 388 - edit-smoke 362건 전건 통과 (Esc 취소 4건 · 되돌리기/저장표시 6건 · 잠김 히트 4건 추가) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 - --dialog-shots · --cleartype 전건 통과 ## 감사에서 나온 나머지 2순위(선택 확장 4종·줌 단축키·Ctrl+방향키 크기·잠금 토글)와 3순위는 아직 안 했다. 레이아웃은 바꾸지 않기로 했다 — 좌 248/우 300 은 이미 측정된 값이고 (레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이 0 스크롤·0 클릭이다. 다만 패널을 접을 수단이 없는 것은 실재하는 부재다. Co-Authored-By: Claude Fable 5 --- .../Behaviors/CanvasKeyboardBehavior.cs | 5 ++ .../Behaviors/CanvasMouseBehavior.cs | 40 +++++++++ src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 83 +++++++++++++++++++ .../Services/InteractionController.cs | 30 +++++++ src/SheetMe.Designer/Services/UndoService.cs | 38 ++++++++- .../ViewModels/DesignerViewModel.cs | 48 +++++++++++ .../Views/LayerPanelView.xaml.cs | 2 + src/SheetMe.Designer/Views/MainView.xaml | 10 +++ 8 files changed, 255 insertions(+), 1 deletion(-) diff --git a/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs index f2b8280..2cb1dbe 100644 --- a/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs +++ b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs @@ -68,6 +68,11 @@ public sealed class CanvasKeyboardBehavior : Behavior case Key.X when ctrl: designer.CutSelection(); break; case Key.V when ctrl: designer.Paste(); break; case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break; + // Ctrl+Shift+Z 는 다시 실행이다. 이 갈래가 없어서 전에는 되돌리기가 한 번 더 됐다 — + // 방향이 반대인 줄 모르는 사용자는 "안 돌아오네" 하며 두세 번 더 눌러 편집분을 통째로 날린다. + // 실패가 조용히 성공하므로(되돌리기는 언제나 성공한다) 경고도 없다. + // 바로 아래 Key.G 가 이미 같은 짝을 가르고 있는데 Z 만 빠져 있었다. + case Key.Z when ctrl && shift: designer.Undo.Redo(); break; case Key.Z when ctrl: designer.Undo.Undo(); break; case Key.Y when ctrl: designer.Undo.Redo(); break; case Key.G when ctrl && shift: designer.UngroupSelection(); break; diff --git a/src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs b/src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs index 1e9e1eb..1dda7ac 100644 --- a/src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs +++ b/src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs @@ -21,6 +21,7 @@ public sealed class CanvasMouseBehavior : Behavior AssociatedObject.MouseMove += OnMouseMove; AssociatedObject.MouseLeftButtonUp += OnMouseUp; AssociatedObject.LostMouseCapture += OnLostCapture; + AssociatedObject.PreviewMouseRightButtonDown += OnRightButtonDown; } protected override void OnDetaching() @@ -29,6 +30,7 @@ public sealed class CanvasMouseBehavior : Behavior AssociatedObject.MouseMove -= OnMouseMove; AssociatedObject.MouseLeftButtonUp -= OnMouseUp; AssociatedObject.LostMouseCapture -= OnLostCapture; + AssociatedObject.PreviewMouseRightButtonDown -= OnRightButtonDown; base.OnDetaching(); } @@ -54,6 +56,44 @@ public sealed class CanvasMouseBehavior : Behavior e.Handled = true; } + /// + /// 우클릭 — 메뉴를 열기 전에 커서 아래 컨트롤을 선택한다. + /// + /// 전에는 우클릭이 선택을 전혀 건드리지 않았다. 라벨 A 를 골라 둔 채 떨어진 체크박스 B 를 + /// 우클릭하면 메뉴는 B 위에 뜨는데 [삭제]는 A 를 지웠다. 선택이 비어 있으면 + /// 메뉴 항목이 전부 살아 있는 채로 아무 일도 하지 않았다. + /// 윈도우의 거의 모든 프로그램이 지키는 규약이라, 사용자는 메뉴가 가리키는 대상을 + /// 확인할 생각조차 하지 않는다 — 20항목짜리 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다. + /// + /// 이미 선택 안에 있으면 선택을 유지한다 — 다중 선택을 우클릭해서 정렬하는 흐름을 깨면 안 된다. + /// e.Handled 는 두지 않는다. 그대로 흘려보내야 기존 ContextMenu 가 평소처럼 열린다. + /// + private void OnRightButtonDown(object sender, MouseButtonEventArgs e) + { + if (Designer is not { } designer || designer.IsTabOrderMode) + { + return; // 탭순서 편집 중에는 클릭이 순번 지정이라 선택을 건드리지 않는다 + } + FocusCanvasHost(); + + var world = e.GetPosition(AssociatedObject); + if (designer.HitTestControl(world) is { } hit) + { + if (!hit.IsSelected) + { + designer.Selection.Set(designer.GroupMatesOf(hit), hit); + } + return; + } + // 빈 곳이면 선택을 푼다 — 다만 잠긴/숨긴 것 위는 빈 곳이 아니다. + // HitTestControl 이 그것들을 후보에서 아예 빼기 때문에(DesignerViewModel) 여기까지 오는데, + // 잠긴 배경 괘선 위에서 우클릭할 때마다 선택이 사라지면 "왜 자꾸 풀리지"가 된다. + if (!designer.AnyControlAt(world)) + { + designer.Selection.Clear(); + } + } + private void OnMouseMove(object sender, MouseEventArgs e) { if (Designer is not { } designer) diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 9e3917d..b790a59 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -1095,6 +1095,57 @@ public static class EditSmoke $"ValueText='{choiceRow.ValueText}' IsMixed={choiceRow.IsMixed}"); } + // 15-b0) 잠긴 것 위는 '빈 곳'이 아니다 — 우클릭이 선택을 지우면 안 된다. + // + // 우클릭이 커서 아래를 선택하도록 고치면서 생긴 함정이다. HitTestControl 은 잠금·숨김을 + // 후보에서 아예 빼므로, 잠가 둔 배경 괘선 위 우클릭이 '빈 곳'으로 판정돼 + // 선택이 통째로 사라진다 — "왜 자꾸 풀리지"만 남는다. + { + var lockDoc = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + lockDoc.AddControlAt("Label", new Point(100, 100)); + var locked = lockDoc.Pages[0].Controls[0]; + locked.X = 100; + locked.Y = 100; + locked.Width = 120; + locked.Height = 20; + var inside = new Point(110, 110); + Check("우클릭: 잠그기 전에는 히트된다", lockDoc.HitTestControl(inside) is not null); + + locked.Model.Locked = true; + Check("우클릭: 잠그면 히트 후보에서 빠진다", lockDoc.HitTestControl(inside) is null); + Check("우클릭: 그래도 '빈 곳'은 아니다", lockDoc.AnyControlAt(inside)); + Check("우클릭: 정말 빈 곳은 빈 곳이다", !lockDoc.AnyControlAt(new Point(600, 700))); + } + + // 15-ba) 되돌리기 짝과 저장 안 됨 표시 — 둘 다 상태는 있는데 조작·화면이 비어 있었다. + { + var hist = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + hist.AddControlAt("Label", new Point(50, 50)); + hist.AddControlAt("Label", new Point(50, 90)); + hist.Undo.Undo(); + hist.Undo.Undo(); + Check("되돌리기: 두 번 되돌리면 0개", hist.Pages[0].Controls.Count == 0, + $"{hist.Pages[0].Controls.Count}개"); + + // Ctrl+Shift+Z = 다시 실행. 전에는 이 갈래가 없어 되돌리기가 한 번 더 됐다 — + // 방향이 반대인 줄 모르는 사용자는 두세 번 더 눌러 편집분을 통째로 날린다. + hist.Undo.Redo(); + Check("되돌리기: 다시 실행으로 하나 돌아온다", hist.Pages[0].Controls.Count == 1, + $"{hist.Pages[0].Controls.Count}개"); + + // 저장 안 됨 표시 — 상태는 정교했는데 화면에 한 번도 안 나왔다 + var dirtySeen = 0; + hist.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(DesignerViewModel.IsDirty)) dirtySeen++; }; + Check("저장 표시: 편집했으면 더티", hist.IsDirty); + hist.Undo.MarkSaved(); + Check("저장 표시: 저장하면 깨끗해진다", !hist.IsDirty); + Check("저장 표시: 값이 바뀌면 화면에 알린다", dirtySeen > 0, $"통지 {dirtySeen}회"); + var before = dirtySeen; + hist.Undo.MarkSaved(); + Check("저장 표시: 안 바뀌면 울리지 않는다", dirtySeen == before, + $"{before} → {dirtySeen}"); + } + // 15-bb) 글꼴은 목록에서 고른다 — 전에는 자유 입력뿐이라 매번 타이핑해야 했고, // 한 글자만 틀려도 레거시가 아무 말 없이 기본 글꼴로 떨어뜨렸다. // @@ -1991,6 +2042,38 @@ public static class EditSmoke designer.Selection.Clear(); } + // 20-5f3) Ctrl+드래그 복사를 Esc 로 취소하면 사본도 사라져야 한다. + // + // 취소가 좌표만 되돌리면 사본이 원본과 픽셀 단위로 겹쳐 살아남는다 — + // 화면은 취소된 것처럼 보이고, 그대로 저장하면 종이에 같은 글자가 두 번 인쇄된다. + // 증거는 "조금 굵어 보인다" 뿐이라 아무도 못 잡는다. + { + var esc = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + esc.AddControlAt("Label", new Point(80, 80)); + var only = esc.Pages[0].Controls[0]; + esc.Selection.SetSingle(only); + var box = esc.WorldBoundsOf(only); + var grab = new Point(box.X + box.Width / 2, box.Y + box.Height / 2); + // 배치 좌표는 자동 어긋내기가 붙을 수 있어 상수로 두면 안 된다 — 실제 값을 읽어 둔다 + var originalX = only.X; + var ctrlAlt = new PointerContext(true, false, true, 1); + + esc.Interaction.PointerDown(grab, ctrlAlt); + esc.Interaction.PointerMove(new Point(grab.X + 40, grab.Y), ctrlAlt); + Check("Esc 취소: 드래그 중에는 사본이 있다", esc.Pages[0].Controls.Count == 2, + $"{esc.Pages[0].Controls.Count}개"); + + esc.Interaction.Cancel(); + Check("Esc 취소: 사본이 사라진다", esc.Pages[0].Controls.Count == 1, + $"{esc.Pages[0].Controls.Count}개 (기대 1)"); + Check("Esc 취소: 원본은 제자리", Math.Abs(esc.Pages[0].Controls[0].X - originalX) < 0.5, + $"X={esc.Pages[0].Controls[0].X} (기대 {originalX})"); + // 취소는 "없던 일로" 다 — Ctrl+Y 로 되살아나면 취소가 아니다 + esc.Undo.Redo(); + Check("Esc 취소: 다시 실행으로 되살아나지 않는다", esc.Pages[0].Controls.Count == 1, + $"{esc.Pages[0].Controls.Count}개 (기대 1)"); + } + // 20-5f2) 패널 안의 것을 Ctrl+드래그하면 사본이 같은 패널 안에 생겨야 한다. // // 레거시 라디오는 GroupName 이 없어 직계 부모가 배타 그룹이다(운영 라디오 98.96%가 diff --git a/src/SheetMe.Designer/Services/InteractionController.cs b/src/SheetMe.Designer/Services/InteractionController.cs index 023403e..81f8b77 100644 --- a/src/SheetMe.Designer/Services/InteractionController.cs +++ b/src/SheetMe.Designer/Services/InteractionController.cs @@ -42,6 +42,12 @@ public sealed class InteractionController private Rect groupBounds0; private readonly Dictionary origBounds = new(); private bool undoCaptured; + + /// + /// 이번 드래그가 Ctrl+복사로 사본을 만들었는가 — Esc 취소가 좌표만 되돌리면 안 되기 때문이다. + /// 이것이 없으면 사본이 원본과 픽셀 단위로 겹쳐 살아남고, 화면은 취소된 것처럼 보인다. + /// + private bool duplicatedThisDrag; #endregion #region Constructors @@ -176,6 +182,7 @@ public sealed class InteractionController // 그러면 "복사 후 이동" 이 Undo 두 번이 되고, 한 번만 누른 사용자는 // 원본 자리에 사본이 겹쳐 남은 상태를 보게 된다. undoCaptured = true; + duplicatedThisDrag = true; } // 복제가 거절되면(표 등) 그냥 이동한다 — 선택은 원본에 남아 있다 BeginMove(); @@ -255,6 +262,7 @@ public sealed class InteractionController mode = Mode.None; downControl = null; origBounds.Clear(); + duplicatedThisDrag = false; // 이 드래그는 끝났다 — 다음 Esc 가 남의 복제를 물면 안 된다 designer.RefreshOverlay(); } @@ -266,6 +274,28 @@ public sealed class InteractionController { if (mode is Mode.Move or Mode.Resize) { + // Ctrl+드래그 복사를 취소하는 경우 — 좌표를 되돌리는 것으로는 부족하다. + // 사본은 이미 문서에 들어가 있고, 원래 자리로 되돌리면 원본과 픽셀 단위로 겹쳐 + // 화면은 취소된 것처럼 보인다. 그대로 저장하면 종이에 같은 글자가 두 번 인쇄되고 + // 증거는 "조금 굵어 보인다" 뿐이다. 그래서 복제 자체를 물린다. + // + // 순서가 중요하다: Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이 + // 죽은 참조가 된다. 상태를 먼저 비우고 그다음에 되돌린다. + if (duplicatedThisDrag) + { + designer.Overlay.ClearGuides(); + designer.Snap.EndDrag(); + mode = Mode.None; + origBounds.Clear(); + downControl = null; + duplicatedThisDrag = false; + undoCaptured = false; + designer.Undo.Undo(); + // 취소는 "없던 일로" 다 — Ctrl+Y 로 사본이 되살아나면 취소가 아니다 + designer.Undo.DiscardRedo(); + designer.RefreshOverlay(); + return; + } foreach (var (vm, rect) in origBounds) { var offset = designer.ParentWorldOffset(vm); diff --git a/src/SheetMe.Designer/Services/UndoService.cs b/src/SheetMe.Designer/Services/UndoService.cs index f3cc2d7..6bde9eb 100644 --- a/src/SheetMe.Designer/Services/UndoService.cs +++ b/src/SheetMe.Designer/Services/UndoService.cs @@ -19,6 +19,9 @@ public sealed class UndoService /// 마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티 private int savedDepth; + + /// 마지막으로 통지한 더티 값 — 바뀔 때만 알리기 위해 들고 있다 + private bool lastDirty; #endregion #region Properties @@ -47,6 +50,12 @@ public sealed class UndoService #region Events /// 복원 발생 통지(Undo/Redo 실행 후) public event Action? Restored; + + /// + /// 값이 바뀌었을 때만 통지 — 탭의 저장 안 됨 표시가 이것으로 따라온다. + /// 매번 통지하면 드래그 한 번에 수백 번 울린다. + /// + public event Action? DirtyChanged; #endregion #region Methods @@ -72,10 +81,15 @@ public sealed class UndoService } redoStack.Clear(); lastNudgeAt = DateTime.MinValue; + RaiseDirtyIfChanged(); } /// 저장 완료 시점 표시 — 이후 편집이 없으면 가 false - public void MarkSaved() => savedDepth = undoStack.Count; + public void MarkSaved() + { + savedDepth = undoStack.Count; + RaiseDirtyIfChanged(); + } /// 넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스 public void SnapshotForNudge() @@ -100,6 +114,27 @@ public sealed class UndoService undoStack.RemoveAt(undoStack.Count - 1); restoreDocument(snapshot); Restored?.Invoke(); + RaiseDirtyIfChanged(); + } + + /// + /// 다시 실행 이력을 버린다 — 취소한 조작이 Ctrl+Y 로 되살아나면 안 될 때. + /// + /// Esc 로 드래그를 취소하는 경우가 그렇다. 사용자는 "되돌린다"가 아니라 "없던 일로 한다"고 + /// 생각하는데, 취소가 Undo 로 구현되어 있으면 그 조작이 redo 이력에 남아 되살아난다. + /// + public void DiscardRedo() => redoStack.Clear(); + + /// 더티 값이 실제로 바뀐 경우에만 통지한다 + private void RaiseDirtyIfChanged() + { + var now = IsDirty; + if (now == lastDirty) + { + return; + } + lastDirty = now; + DirtyChanged?.Invoke(); } /// 다시 실행 @@ -114,6 +149,7 @@ public sealed class UndoService redoStack.RemoveAt(redoStack.Count - 1); restoreDocument(snapshot); Restored?.Invoke(); + RaiseDirtyIfChanged(); } #endregion } diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index 48194fb..b0c0dd8 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -129,6 +129,21 @@ public sealed class DesignerViewModel : ViewModelBase /// 탭 헤더 갱신 통지(저장/개명 후) public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName)); + /// + /// 저장하지 않은 변경이 있는가 — 탭에 점으로 보인다. + /// + /// 이 상태는 가 저장 지점 깊이까지 추적해 정교하게 들고 + /// 있었는데(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다. + /// 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤에는, 어느 것이 안 저장됐는지 + /// 확인할 방법이 없다 — 탭 제목이 저장 전후로 글자 하나 다르지 않다. + /// 그래서 안전하게 전부 다시 저장하게 되는데, DB 저장은 새 버전을 만든다 — + /// 서식마다 쓸데없는 버전이 쌓인다. + /// + public bool IsDirty => Undo.IsDirty; + + /// 더티 표시 갱신 통지 — 편집·저장·되돌리기 뒤에 부른다 + public void NotifyDirtyChanged() => OnPropertyChanged(nameof(IsDirty)); + /// 페이지 VM 목록 public ObservableCollection Pages { get; } = new(); @@ -290,6 +305,8 @@ public sealed class DesignerViewModel : ViewModelBase ScreenTolerance = ConfigService.Current.SnapThreshold, }; Undo = new UndoService(() => Document, ReplaceDocument); + // 저장 안 됨 표시가 탭에 따라오게 한다 — 값이 바뀔 때만 울린다 + Undo.DirtyChanged += NotifyDirtyChanged; Interaction = new InteractionController(this); Inspector = new InspectorViewModel(this); Selection.Changed += RefreshOverlay; @@ -764,6 +781,37 @@ public sealed class DesignerViewModel : ViewModelBase return HitInList(page.Controls, local); } + /// + /// 이 좌표에 무엇이든 있는가 — 잠긴 것·숨긴 것까지 포함해서. + /// + /// 은 잠금·숨김을 후보에서 아예 빼므로 그 위를 "빈 곳"이라고 답한다. + /// 선택을 푸는 판단에는 그 답을 쓰면 안 된다 — 잠가 둔 배경 괘선 위에서 우클릭할 때마다 + /// 선택이 사라져 "왜 자꾸 풀리지"가 된다. 잠금은 편집 보호지 부재가 아니다. + /// + public bool AnyControlAt(Point world) + { + var page = PageAt(world.Y); + if (page is null) + { + return false; + } + return AnyInList(page.Controls, new Point(world.X, world.Y - page.OffsetY)); + } + + private static bool AnyInList(IList controls, Point local) + { + foreach (var vm in controls) + { + var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height)); + if (!rect.Contains(local)) + { + continue; + } + return true; + } + return false; + } + private static ControlViewModel? HitInList(IList controls, Point local) { for (var i = controls.Count - 1; i >= 0; i--) diff --git a/src/SheetMe.Designer/Views/LayerPanelView.xaml.cs b/src/SheetMe.Designer/Views/LayerPanelView.xaml.cs index eeb21f5..8b78020 100644 --- a/src/SheetMe.Designer/Views/LayerPanelView.xaml.cs +++ b/src/SheetMe.Designer/Views/LayerPanelView.xaml.cs @@ -103,6 +103,8 @@ public partial class LayerPanelView : System.Windows.Controls.UserControl case System.Windows.Input.Key.X when ctrl: designer.CutSelection(); break; case System.Windows.Input.Key.V when ctrl: designer.Paste(); break; case System.Windows.Input.Key.D when ctrl: designer.CopySelection(); designer.Paste(); break; + // 캔버스와 같은 짝 — 레이어 목록에 포커스가 있을 때도 Ctrl+Shift+Z 는 다시 실행이다 + case System.Windows.Input.Key.Z when ctrl && shift: designer.Undo.Redo(); break; case System.Windows.Input.Key.Z when ctrl: designer.Undo.Undo(); break; case System.Windows.Input.Key.Y when ctrl: designer.Undo.Redo(); break; case System.Windows.Input.Key.Delete: designer.DeleteSelection(); break; diff --git a/src/SheetMe.Designer/Views/MainView.xaml b/src/SheetMe.Designer/Views/MainView.xaml index fef7bb3..fd5408f 100644 --- a/src/SheetMe.Designer/Views/MainView.xaml +++ b/src/SheetMe.Designer/Views/MainView.xaml @@ -599,6 +599,16 @@ + +