사용성 감사를 돌렸더니 결론이 뜻밖이었다. <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>
288 lines
12 KiB
C#
288 lines
12 KiB
C#
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
|
||
}
|