Files
SheetMe/src/SheetMe.Designer/Views/LayerPanelView.xaml.cs
T
MsystechandClaude Fable 5 fccd441193 사용성 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>
2026-08-20 17:16:15 +09:00

288 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Windows;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Views;
/// <summary>
/// 레이어 아웃라인 패널 — [200]SheetMe 이식본.
///
/// 여기 있는 코드비하인드는 전부 <b>ListBox 가 커맨드로 표현할 수 없는 입력</b>이다:
/// 더블클릭(InputBinding 미지원), 선택 대상이 아닌 행의 클릭, 인라인 편집의 포커스 인계.
/// 문서를 바꾸는 판단은 전부 <see cref="DesignerViewModel"/> 에 위임한다.
/// </summary>
public partial class LayerPanelView : System.Windows.Controls.UserControl
{
#region Constructors
public LayerPanelView() => InitializeComponent();
#endregion
#region Properties
/// <summary>현재 문서의 디자이너 — DataContext 는 창에서 상속받는다</summary>
private DesignerViewModel? Designer => (DataContext as MainViewModel)?.CurrentDesigner;
#endregion
#region Methods - 조작
/// <summary>
/// 레이어 더블클릭 → 컨트롤은 캔버스 이동, 그룹 폴더는 이름 편집 시작.
///
/// 컨트롤이 수백 개인 서식에서는 목록에서 이름을 찾아도 종이 어디에 있는지 알 수 없다.
/// [200] 은 더블클릭을 컨트롤 이름 편집에 쓰지만 그 경로는 이식하지 않았다 —
/// [200] 이 고치는 값은 표시 전용 별칭이고, SheetMe 의 컨트롤 이름은 <b>외부 참조 키</b>라
/// 목록에서 무심코 고치면 액션 대상·CalcBox 수식 배선이 끊긴다.
/// </summary>
private void OnLayerDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (Designer is not { } designer)
{
return;
}
switch (LayerListBox.SelectedItem)
{
case ViewModels.Controls.ControlViewModel target:
designer.RevealControl(target);
break;
case LayerGroupViewModel group:
group.IsEditing = true;
break;
}
}
/// <summary>
/// 레이어 행 눌림 — 페이지 헤더·'페이지 추가'처럼 선택 대상이 아닌 행의 클릭을 여기서 받는다.
///
/// 이 두 행은 <c>Focusable=False</c> 라 ListBox 선택 경로를 타지 않는다.
/// 행 안의 버튼(복제·삭제·셰브론)은 자기 Click 을 따로 처리하므로 먼저 걸러 낸다.
/// </summary>
private void OnLayerRowPressed(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (Designer is not { } designer)
{
return;
}
if (e.OriginalSource is DependencyObject source
&& FindAncestor<System.Windows.Controls.Primitives.ButtonBase>(source) is not null)
{
return;
}
switch (RowDataOf(e.OriginalSource))
{
case LayerPageViewModel page:
designer.ActivatePage(page.Page);
break;
}
}
/// <summary>
/// 레이어 목록 단축키 — 그룹/삭제/복사/되돌리기.
///
/// 이 키들은 원래 <see cref="Behaviors.CanvasKeyboardBehavior"/> 가 처리하는데, 그 behavior 는
/// <b>캔버스에 붙어 있어서</b> 포커스가 목록에 있으면 이벤트가 거기까지 가지 않는다.
/// 목록에서 여러 행을 골라 놓고 Ctrl+G 를 눌렀을 때 아무 일도 안 일어나던 이유가 이것이다.
///
/// 방향키·Home/End·문자 타이핑은 <b>넘기지 않는다</b> — 목록 자체의 이동·형식 검색이 우선이다.
/// 그룹 이름을 편집 중일 때도 통과시킨다(입력 보호).
/// </summary>
private void OnLayerListKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (Designer is not { } designer
|| System.Windows.Input.Keyboard.FocusedElement
is System.Windows.Controls.Primitives.TextBoxBase or System.Windows.Controls.ComboBox)
{
return;
}
var ctrl = System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control);
var shift = System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift);
var handled = true;
switch (e.Key)
{
case System.Windows.Input.Key.G when ctrl && shift: designer.UngroupSelection(); break;
case System.Windows.Input.Key.G when ctrl: designer.GroupSelection(); break;
case System.Windows.Input.Key.C when ctrl: designer.CopySelection(); break;
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;
case System.Windows.Input.Key.Escape: designer.Selection.Clear(); break;
default: handled = false; break;
}
e.Handled = handled;
}
/// <summary>돋보기를 켰다 — 상자가 실제로 보이게 된 뒤에 포커스를 준다(한 틱 뒤)</summary>
private void OnLayerSearchOpened(object sender, RoutedEventArgs e)
=> Dispatcher.BeginInvoke(new Action(() =>
{
LayerSearchBox.Focus();
System.Windows.Input.Keyboard.Focus(LayerSearchBox);
}), System.Windows.Threading.DispatcherPriority.Input);
/// <summary>
/// 돋보기를 껐다 — 검색어를 지운다.
/// 상자가 접힌 채 필터만 살아 있으면 목록이 통째로 비어 보이고 원인을 찾을 단서가 화면에 없다.
/// </summary>
private void OnLayerSearchClosed(object sender, RoutedEventArgs e)
{
if (Designer is { } designer)
{
designer.LayerFilter = string.Empty;
}
}
/// <summary>페이지 구획의 — 트리 안에 있던 '페이지 추가' 행을 대신한다</summary>
private void OnPageAddClick(object sender, RoutedEventArgs e) => Designer?.AddPage();
/// <summary>
/// 페이지 행 누름 — 그 페이지를 활성화한다(캔버스가 거기로 스크롤하고 인스펙터가 용지 속성을 연다).
///
/// ListBox 의 선택으로 처리하지 않는 이유: 복제·삭제 버튼을 눌러도 행 선택이 먼저 일어나
/// 엉뚱한 페이지가 활성화된다. 눌린 지점이 버튼 위면 넘긴다.
/// </summary>
private void OnPageRowPressed(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject source
&& FindAncestor<System.Windows.Controls.Primitives.ButtonBase>(source) is not null)
{
return;
}
if (RowDataOf(e.OriginalSource) is LayerPageViewModel row)
{
Designer?.ActivatePage(row.Page);
}
}
/// <summary>페이지 헤더의 복제 버튼</summary>
private void OnPageDuplicateClick(object sender, RoutedEventArgs e)
{
if (Designer is { } designer && (sender as FrameworkElement)?.DataContext is LayerPageViewModel row)
{
designer.DuplicatePage(row.Page);
}
}
/// <summary>페이지 헤더의 삭제 버튼</summary>
private void OnPageDeleteClick(object sender, RoutedEventArgs e)
{
if (Designer is { } designer && (sender as FrameworkElement)?.DataContext is LayerPageViewModel row)
{
designer.RemovePage(row.Page);
}
}
#endregion
#region Methods - 그룹 이름 인라인 편집
/// <summary>
/// 이름 편집 상자가 <b>보이게 된 순간</b> 포커스+전체선택 — 더블클릭 직후 바로 덮어쓸 수 있게.
/// Loaded 로는 안 된다: 행이 만들어질 때 상자는 아직 접혀 있고, 나중에 펼쳐질 때 Loaded 는 다시 오지 않는다.
/// 표시가 실제로 반영된 뒤에 포커스를 줘야 하므로 한 틱 미룬다.
/// </summary>
private void OnGroupNameBoxVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is not System.Windows.Controls.TextBox box || !box.IsVisible)
{
return;
}
Dispatcher.BeginInvoke(new Action(() =>
{
box.Focus();
System.Windows.Input.Keyboard.Focus(box);
box.SelectAll();
}), System.Windows.Threading.DispatcherPriority.Input);
}
/// <summary>Enter=확정 / Esc=취소 — 둘 다 편집 상태를 끈다</summary>
private void OnGroupNameKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (sender is not System.Windows.Controls.TextBox box)
{
return;
}
if (e.Key == System.Windows.Input.Key.Enter)
{
box.GetBindingExpression(System.Windows.Controls.TextBox.TextProperty)?.UpdateSource();
EndGroupNameEdit(box);
e.Handled = true;
}
else if (e.Key == System.Windows.Input.Key.Escape)
{
box.GetBindingExpression(System.Windows.Controls.TextBox.TextProperty)?.UpdateTarget();
EndGroupNameEdit(box);
e.Handled = true;
}
}
/// <summary>포커스가 빠지면 확정(LostFocus 바인딩이 이미 값을 밀어 넣는다)</summary>
private void OnGroupNameLostFocus(object sender, RoutedEventArgs e)
{
if (sender is System.Windows.Controls.TextBox box)
{
EndGroupNameEdit(box);
}
}
private static void EndGroupNameEdit(System.Windows.Controls.TextBox box)
{
if (box.DataContext is LayerGroupViewModel group)
{
group.IsEditing = false;
}
}
#endregion
#region Methods - 타입 필터
/// <summary>레이어 타입 필터 — 문서에 실제로 있는 타입만 후보로 띄운다</summary>
private void OnLayerFilterClick(object sender, RoutedEventArgs e)
{
if (Designer is not { } designer)
{
return;
}
var dialog = new LayerTypeFilterDialog(designer.LayerTypeCandidates(), designer.LayerTypeFilter)
{
Owner = Window.GetWindow(this),
};
if (dialog.ShowDialog() == true)
{
designer.SetLayerTypeFilter(dialog.SelectedTypes);
}
}
/// <summary>타입 필터 해제</summary>
private void OnLayerFilterClearClick(object sender, RoutedEventArgs e)
=> Designer?.SetLayerTypeFilter(Array.Empty<string>());
#endregion
#region Methods - 트리 탐색
/// <summary>눌린 지점이 속한 레이어 행의 데이터</summary>
private static object? RowDataOf(object? source)
=> source is DependencyObject node
? FindAncestor<System.Windows.Controls.ListBoxItem>(node)?.DataContext
: null;
/// <summary>
/// 조상 찾기 — 시각 트리를 따라 오르되, 시각 요소가 아닌 노드(FrameworkContentElement 등)에서는
/// 논리 트리로 갈아탄다. VisualTreeHelper 만 쓰면 그런 노드에서 예외가 난다.
/// </summary>
private static T? FindAncestor<T>(DependencyObject? node) where T : DependencyObject
{
while (node is not null)
{
if (node is T match)
{
return match;
}
node = node is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
? System.Windows.Media.VisualTreeHelper.GetParent(node)
: LogicalTreeHelper.GetParent(node);
}
return null;
}
#endregion
}