사용성 1순위 — 조용히 틀리게 동작하던 넷을 고친다

사용성 감사를 돌렸더니 결론이 뜻밖이었다. <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>
This commit is contained in:
Msystech
2026-08-20 17:16:15 +09:00
co-authored by Claude Fable 5
parent be9d697a0d
commit fccd441193
8 changed files with 255 additions and 1 deletions
@@ -42,6 +42,12 @@ public sealed class InteractionController
private Rect groupBounds0;
private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
private bool undoCaptured;
/// <summary>
/// 이번 드래그가 Ctrl+복사로 <b>사본을 만들었는가</b> — Esc 취소가 좌표만 되돌리면 안 되기 때문이다.
/// 이것이 없으면 사본이 원본과 픽셀 단위로 겹쳐 살아남고, 화면은 취소된 것처럼 보인다.
/// </summary>
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+드래그 복사를 취소하는 경우 — 좌표를 되돌리는 것으로는 부족하다.
// 사본은 이미 <b>문서에 들어가 있고</b>, 원래 자리로 되돌리면 원본과 픽셀 단위로 겹쳐
// 화면은 취소된 것처럼 보인다. 그대로 저장하면 종이에 같은 글자가 두 번 인쇄되고
// 증거는 "조금 굵어 보인다" 뿐이다. 그래서 복제 자체를 물린다.
//
// 순서가 중요하다: Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이
// 죽은 참조가 된다. 상태를 <b>먼저</b> 비우고 그다음에 되돌린다.
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);
+37 -1
View File
@@ -19,6 +19,9 @@ public sealed class UndoService
/// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary>
private int savedDepth;
/// <summary>마지막으로 통지한 더티 값 — 바뀔 때만 알리기 위해 들고 있다</summary>
private bool lastDirty;
#endregion
#region Properties
@@ -47,6 +50,12 @@ public sealed class UndoService
#region Events
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
public event Action? Restored;
/// <summary>
/// <see cref="IsDirty"/> 값이 <b>바뀌었을 때만</b> 통지 — 탭의 저장 안 됨 표시가 이것으로 따라온다.
/// 매번 통지하면 드래그 한 번에 수백 번 울린다.
/// </summary>
public event Action? DirtyChanged;
#endregion
#region Methods
@@ -72,10 +81,15 @@ public sealed class UndoService
}
redoStack.Clear();
lastNudgeAt = DateTime.MinValue;
RaiseDirtyIfChanged();
}
/// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary>
public void MarkSaved() => savedDepth = undoStack.Count;
public void MarkSaved()
{
savedDepth = undoStack.Count;
RaiseDirtyIfChanged();
}
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
public void SnapshotForNudge()
@@ -100,6 +114,27 @@ public sealed class UndoService
undoStack.RemoveAt(undoStack.Count - 1);
restoreDocument(snapshot);
Restored?.Invoke();
RaiseDirtyIfChanged();
}
/// <summary>
/// 다시 실행 이력을 버린다 — <b>취소</b>한 조작이 Ctrl+Y 로 되살아나면 안 될 때.
///
/// Esc 로 드래그를 취소하는 경우가 그렇다. 사용자는 "되돌린다"가 아니라 "없던 일로 한다"고
/// 생각하는데, 취소가 Undo 로 구현되어 있으면 그 조작이 redo 이력에 남아 되살아난다.
/// </summary>
public void DiscardRedo() => redoStack.Clear();
/// <summary>더티 값이 실제로 바뀐 경우에만 통지한다</summary>
private void RaiseDirtyIfChanged()
{
var now = IsDirty;
if (now == lastDirty)
{
return;
}
lastDirty = now;
DirtyChanged?.Invoke();
}
/// <summary>다시 실행</summary>
@@ -114,6 +149,7 @@ public sealed class UndoService
redoStack.RemoveAt(redoStack.Count - 1);
restoreDocument(snapshot);
Restored?.Invoke();
RaiseDirtyIfChanged();
}
#endregion
}