레이어 패널을 [200]SheetMe 아웃라인으로 교체 — 페이지 노드·선택 밴드·드래그 재정렬

[200] 의 패널은 코드로 Border 를 쌓는 비가상화 렌더고 SheetMe 는 가상화 ListBox 다.
골격은 SheetMe 것을 지키고 겉모습과 조작 표면만 옮겼다 — 통째로 베끼면 1,067 컨트롤
서식에서 검색어 한 글자마다 수천 비주얼을 다시 만들게 되고, [200] 에 대응물이 없는
컨테이너 자식 재귀 표시가 사라진다.

구조
- 페이지를 별도 섹션이 아니라 아웃라인의 최상위 노드로. 접기·복제·삭제·컨트롤 수·현재 강조
- 맨 아래 '+ 페이지 추가' 행
- 목록 순서를 뒤집었다 — 이제 맨 위가 z-order 최상위(캔버스에서 위에 겹쳐 보이는 것이 목록 위)
- 들여쓰기 12 / 32 / +16
- 레이어 패널을 LayerPanelView 로 분리(MainView.xaml 이 1,000줄을 넘겼고, 진단 렌더러가
  이 패널만 따로 찍어 라이트·다크·선택 상태를 검사할 수 있어야 한다)

선택 표시
- IsSelected 가 아니라 Highlight 3상태(None/Blue/Navy)가 배경을 그린다. 그룹 전원 선택 시
  폴더는 파랑, 멤버는 서브트리 배경이 되고 맞닿는 모서리를 각지게 해 하나의 블록으로 이어진다
- 밴드 위에서는 아이콘·보조 텍스트 색도 함께 뒤집는다. 안 그러면 눈·자물쇠가 1.6:1 로 묻혀
  선택한 행에서만 잠금 상태가 안 보이는 상태가 된다
- 신설 토큰 3종(B.LayerSel / B.LayerSubtree / B.OnSelMuted) — [200] 원본값은 각각
  흰 글자 3.00:1, 다크 밴드 1.03:1, 라이트 밴드 1.14:1 로 이 저장소가 이미 기각한 값이다

기능
- 드래그로 z-order 재정렬(삽입선 어도너). 임의 index 이동 연산 MoveWithinParent 신설
- 페이지 복제 — 컨트롤 이름을 전부 새로 발급하고 그룹도 복제본끼리 다시 묶는다
  ([200] 은 GroupId 를 그대로 둬서 복제 그룹이 원본과 gid 를 공유한다)
- 페이지 헤더 클릭 → 캔버스가 그 페이지로 이동(ScrollToPage)
- 데이터 배선 배지 — 값이 조회·데이터소스에서 오는 컨트롤 표시
- 검색칩에 테두리(라이트에서 B.Input=B.Panel 이라 경계 대비가 1.00:1 이었다)

이식하지 않은 것: 컨트롤 행 인라인 개명. [200] 이 고치는 값은 표시 전용 별칭이지만
SheetMe 의 컨트롤 이름은 외부 참조 키라 목록에서 무심코 고치면 배선이 끊긴다.
더블클릭은 요청대로 캔버스 이동을 유지하고, 그룹 행에서만 이름 편집으로 간다.

동반 수정: 눈·자물쇠 토글에 되돌리기 지점 추가(400ms 코얼레스), 그룹 이름 비우기 경로가
스냅숏 없이 지워지던 것, 숨김 흐리기를 행 전체가 아니라 이름 줄에만.

편집 스모크의 레이어 검사 19 → 42건. 회귀: 테스트 124/124, 편집 스모크 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-12 17:48:48 +09:00
co-authored by Claude Opus 5
parent a30421f9b5
commit 76584e5004
17 changed files with 1733 additions and 328 deletions
@@ -0,0 +1,205 @@
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;
case LayerAddPageViewModel:
designer.AddPage();
break;
}
}
/// <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
}