사용성 감사를 돌렸더니 결론이 뜻밖이었다. <b>없는 기능이 문제가 아니었다</b> — 정렬 6종·간격 4종·같은 크기·그룹·스냅 가이드·레이어 트리·일괄 편집이 이미 다 있다. 진짜 문제는 있는 것이 틀리게 동작하고, 그 실패가 화면에 아무 흔적을 남기지 않는 것이었다. 넷 다 사용자가 무엇을 잃었는지 모르는 채로 저장하는 경로다. ## ① Ctrl+Shift+Z 가 다시 실행이 아니라 되돌리기였다 CanvasKeyboardBehavior 의 `case Key.Z when ctrl` 에 shift 검사가 없었다. 바로 아래 Key.G 는 ctrl && shift 를 가르는데 Z 만 빠져 있었다. 두 번 되돌린 뒤 한 단계 물리려 Ctrl+Shift+Z 를 누르면 <b>세 단계 전</b>으로 간다. 방향이 반대인 줄 모르면 두세 번 더 눌러 편집분을 통째로 날리는데, 되돌리기는 언제나 성공하므로 경고가 없다. 레이어 패널 쪽도 같은 짝이라 함께 고쳤다. ## ② Ctrl+드래그 복사를 Esc 로 취소하면 사본이 남았다 이번 세션에 Ctrl+드래그 복사를 넣으면서 만든 회귀다. CancelDrag 는 좌표만 되돌리는데, 사본은 이미 문서에 들어가 있으므로 원래 자리로 되돌리면 <b>원본과 픽셀 단위로 겹친다</b>. 화면은 취소된 것처럼 보이고, 그대로 저장하면 종이에 같은 글자가 두 번 인쇄된다 — 증거는 "조금 굵어 보인다" 뿐이다. 복제한 드래그였으면 좌표 복원 대신 복제 자체를 물린다. 순서가 중요하다: Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이 죽은 참조가 된다 — 상태를 먼저 비우고 그다음에 되돌린다. 그리고 DiscardRedo 로 redo 이력도 버린다. Esc 는 "되돌린다"가 아니라 "없던 일로 한다"이므로, Ctrl+Y 로 되살아나면 취소가 아니다. ## ③ 우클릭이 커서 아래를 잡지 않았다 우클릭 메뉴는 20항목인데 우클릭 <b>선택</b> 핸들러가 저장소 전역에 0건이었다. 라벨 A 를 골라 둔 채 떨어진 체크박스 B 를 우클릭하면 메뉴는 B 위에 뜨는데 [삭제]는 A 를 지운다. 선택이 비어 있으면 항목이 전부 살아 있는 채로 아무 일도 안 한다. 윈도우의 거의 모든 프로그램이 지키는 규약이라 사용자는 메뉴가 가리키는 대상을 확인할 생각조차 하지 않는다 — 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다. 이미 선택 안에 있으면 선택을 유지한다(다중 선택 우클릭 정렬 흐름을 깨면 안 된다). e.Handled 는 두지 않아 기존 ContextMenu 가 그대로 열린다. <b>함정 하나</b>: HitTestControl 은 잠금·숨김을 히트 후보에서 아예 빼므로, 잠가 둔 배경 괘선 위 우클릭이 '빈 곳'으로 판정돼 선택이 사라진다. 잠금은 편집 보호지 부재가 아니다 — AnyControlAt 을 따로 만들어 그 위에서는 선택을 지우지 않는다. ## ④ 저장 안 된 문서를 화면에서 구별할 수 없었다 UndoService.IsDirty 는 저장 지점 깊이까지 추적해 정교하게 있었는데 (되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다. 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤, 어느 것이 안 저장됐는지 알 방법이 없다 — 탭 제목이 저장 전후로 글자 하나 다르지 않다. 그래서 안전하게 전부 다시 저장하게 되는데 <b>DB 저장은 새 버전을 만든다</b> — 서식마다 쓸데없는 버전이 쌓인다. 탭에 6px 점을 찍는다. 통지는 값이 <b>바뀔 때만</b> 울린다 — 매번 울리면 드래그 한 번에 수백 번이다. ## 판정 - 단위 시험 388 - edit-smoke 362건 전건 통과 (Esc 취소 4건 · 되돌리기/저장표시 6건 · 잠김 히트 4건 추가) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 - --dialog-shots · --cleartype 전건 통과 ## 감사에서 나온 나머지 2순위(선택 확장 4종·줌 단축키·Ctrl+방향키 크기·잠금 토글)와 3순위는 아직 안 했다. <b>레이아웃은 바꾸지 않기로 했다</b> — 좌 248/우 300 은 이미 측정된 값이고 (레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이 0 스크롤·0 클릭이다. 다만 패널을 접을 수단이 없는 것은 실재하는 부재다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
151 lines
5.9 KiB
C#
151 lines
5.9 KiB
C#
using System.Windows;
|
|
using System.Windows.Input;
|
|
using Microsoft.Xaml.Behaviors;
|
|
using SheetMe.Designer.Services;
|
|
using SheetMe.Designer.ViewModels;
|
|
|
|
namespace SheetMe.Designer.Behaviors;
|
|
|
|
/// <summary>
|
|
/// 캔버스 마우스 입력 → 월드 좌표 → InteractionController 위임.
|
|
/// World 요소(LayoutTransform 줌 하위)에 부착 — GetPosition(World)가 줌 불변 논리좌표를 반환.
|
|
/// 로직 없음: 좌표 변환·캡처·커서 매핑만 담당.
|
|
/// </summary>
|
|
public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
|
|
{
|
|
#region Methods
|
|
protected override void OnAttached()
|
|
{
|
|
base.OnAttached();
|
|
AssociatedObject.MouseLeftButtonDown += OnMouseDown;
|
|
AssociatedObject.MouseMove += OnMouseMove;
|
|
AssociatedObject.MouseLeftButtonUp += OnMouseUp;
|
|
AssociatedObject.LostMouseCapture += OnLostCapture;
|
|
AssociatedObject.PreviewMouseRightButtonDown += OnRightButtonDown;
|
|
}
|
|
|
|
protected override void OnDetaching()
|
|
{
|
|
AssociatedObject.MouseLeftButtonDown -= OnMouseDown;
|
|
AssociatedObject.MouseMove -= OnMouseMove;
|
|
AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
|
|
AssociatedObject.LostMouseCapture -= OnLostCapture;
|
|
AssociatedObject.PreviewMouseRightButtonDown -= OnRightButtonDown;
|
|
base.OnDetaching();
|
|
}
|
|
|
|
private DesignerViewModel? Designer => AssociatedObject.DataContext as DesignerViewModel;
|
|
|
|
private static PointerContext ContextOf(int clickCount = 1) => new(
|
|
Keyboard.Modifiers.HasFlag(ModifierKeys.Control),
|
|
Keyboard.Modifiers.HasFlag(ModifierKeys.Shift),
|
|
Keyboard.Modifiers.HasFlag(ModifierKeys.Alt),
|
|
clickCount);
|
|
|
|
private void OnMouseDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (Designer is not { } designer)
|
|
{
|
|
return;
|
|
}
|
|
// 키보드 포커스를 캔버스 컨테이너(Focusable 상위)로 — 단축키 수신
|
|
FocusCanvasHost();
|
|
|
|
designer.Interaction.PointerDown(e.GetPosition(AssociatedObject), ContextOf(e.ClickCount));
|
|
AssociatedObject.CaptureMouse();
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 우클릭 — 메뉴를 열기 <b>전에</b> 커서 아래 컨트롤을 선택한다.
|
|
///
|
|
/// 전에는 우클릭이 선택을 전혀 건드리지 않았다. 라벨 A 를 골라 둔 채 떨어진 체크박스 B 를
|
|
/// 우클릭하면 메뉴는 B 위에 뜨는데 [삭제]는 <b>A 를 지웠다</b>. 선택이 비어 있으면
|
|
/// 메뉴 항목이 전부 살아 있는 채로 아무 일도 하지 않았다.
|
|
/// 윈도우의 거의 모든 프로그램이 지키는 규약이라, 사용자는 메뉴가 가리키는 대상을
|
|
/// 확인할 생각조차 하지 않는다 — 20항목짜리 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다.
|
|
///
|
|
/// <b>이미 선택 안에 있으면 선택을 유지한다</b> — 다중 선택을 우클릭해서 정렬하는 흐름을 깨면 안 된다.
|
|
/// <c>e.Handled</c> 는 두지 않는다. 그대로 흘려보내야 기존 ContextMenu 가 평소처럼 열린다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
// 빈 곳이면 선택을 푼다 — 다만 <b>잠긴/숨긴 것 위</b>는 빈 곳이 아니다.
|
|
// HitTestControl 이 그것들을 후보에서 아예 빼기 때문에(DesignerViewModel) 여기까지 오는데,
|
|
// 잠긴 배경 괘선 위에서 우클릭할 때마다 선택이 사라지면 "왜 자꾸 풀리지"가 된다.
|
|
if (!designer.AnyControlAt(world))
|
|
{
|
|
designer.Selection.Clear();
|
|
}
|
|
}
|
|
|
|
private void OnMouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
if (Designer is not { } designer)
|
|
{
|
|
return;
|
|
}
|
|
var world = e.GetPosition(AssociatedObject);
|
|
if (e.LeftButton == MouseButtonState.Pressed && AssociatedObject.IsMouseCaptured)
|
|
{
|
|
designer.Interaction.PointerMove(world, ContextOf());
|
|
}
|
|
AssociatedObject.Cursor = MapCursor(designer.Interaction.CursorAt(world));
|
|
}
|
|
|
|
private void OnMouseUp(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (Designer is not { } designer)
|
|
{
|
|
return;
|
|
}
|
|
designer.Interaction.PointerUp(e.GetPosition(AssociatedObject), ContextOf());
|
|
AssociatedObject.ReleaseMouseCapture();
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void OnLostCapture(object sender, MouseEventArgs e)
|
|
=> Designer?.Interaction.CancelDrag(); // 정상 업 이후에는 no-op — 선택 유지
|
|
|
|
private void FocusCanvasHost()
|
|
{
|
|
// 상위 DesignerCanvasView(Focusable)로 포커스 — 키보드 단축키 수신
|
|
DependencyObject? current = AssociatedObject;
|
|
while (current is not null)
|
|
{
|
|
if (current is FrameworkElement { Focusable: true } focusable)
|
|
{
|
|
focusable.Focus();
|
|
return;
|
|
}
|
|
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
|
|
}
|
|
}
|
|
|
|
private static Cursor MapCursor(CursorKind kind) => kind switch
|
|
{
|
|
CursorKind.SizeAll => Cursors.SizeAll,
|
|
CursorKind.SizeNWSE => Cursors.SizeNWSE,
|
|
CursorKind.SizeNESW => Cursors.SizeNESW,
|
|
CursorKind.SizeNS => Cursors.SizeNS,
|
|
CursorKind.SizeWE => Cursors.SizeWE,
|
|
_ => Cursors.Arrow,
|
|
};
|
|
#endregion
|
|
}
|