레이어 패널을 [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,262 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Behaviors;
/// <summary>
/// 레이어 목록 드래그 재정렬 — 행을 끌어다 놓아 z-order(겹침 순서)를 바꾼다.
///
/// 목록은 <b>z-order 최상위가 맨 위</b>이므로 위로 끌면 앞으로 나오고 아래로 끌면 뒤로 물러난다.
/// 대상은 <b>같은 부모 안의 최상위 컨트롤</b>끼리로 제한한다 — 컨테이너 안팎으로 옮기는 것은
/// 좌표계가 바뀌는 별개의 연산이고, 그룹 폴더·페이지 행은 순서를 갖지 않는다.
///
/// z-order 는 저장되는 값이라(XML Object 순서) 오작동이 실제 서식의 겹침을 망가뜨린다.
/// 그래서 드롭 위치 판정은 행 높이의 절반 기준으로 단순하게 두고, 삽입선을 눈으로 보여준다.
/// </summary>
public static class LayerDragBehavior
{
#region Attached Properties
/// <summary>드래그 재정렬을 붙일 디자이너 — 문서 탭이 바뀌면 갈아탄다</summary>
public static readonly DependencyProperty DesignerProperty = DependencyProperty.RegisterAttached(
"Designer", typeof(DesignerViewModel), typeof(LayerDragBehavior),
new PropertyMetadata(null, OnDesignerChanged));
public static void SetDesigner(DependencyObject element, DesignerViewModel? value)
=> element.SetValue(DesignerProperty, value);
public static DesignerViewModel? GetDesigner(DependencyObject element)
=> (DesignerViewModel?)element.GetValue(DesignerProperty);
#endregion
#region Member Fields
private const double DragThreshold = 5;
private static ListBox? activeList;
private static ControlViewModel? dragSource;
private static Point pressPoint;
private static bool pressed;
private static InsertionAdorner? adorner;
#endregion
#region Methods
private static void OnDesignerChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
if (sender is not ListBox list)
{
return;
}
list.PreviewMouseLeftButtonDown -= OnPress;
list.PreviewMouseMove -= OnMove;
list.PreviewMouseLeftButtonUp -= OnRelease;
list.QueryContinueDrag -= OnQueryContinue;
list.DragOver -= OnDragOver;
list.Drop -= OnDrop;
list.DragLeave -= OnDragLeave;
if (e.NewValue is not DesignerViewModel)
{
return;
}
list.AllowDrop = true;
list.PreviewMouseLeftButtonDown += OnPress;
list.PreviewMouseMove += OnMove;
list.PreviewMouseLeftButtonUp += OnRelease;
list.QueryContinueDrag += OnQueryContinue;
list.DragOver += OnDragOver;
list.Drop += OnDrop;
list.DragLeave += OnDragLeave;
}
/// <summary>누른 지점 기억 — 실제 드래그는 임계 거리를 넘어야 시작한다(클릭 선택을 방해하지 않도록)</summary>
private static void OnPress(object sender, MouseButtonEventArgs e)
{
if (sender is not ListBox list)
{
return;
}
// 눈·자물쇠·셰브론 같은 행 안의 버튼에서 시작한 누름은 드래그가 아니다
if (e.OriginalSource is DependencyObject origin
&& FindAncestor<System.Windows.Controls.Primitives.ButtonBase>(origin) is not null)
{
return;
}
var row = RowDataOf(e.OriginalSource);
// 최상위 컨트롤만 — 컨테이너 자식은 부모 안에서만 의미가 있어 목록 드래그로 다루지 않는다
dragSource = row is ControlViewModel control && control.Parent is null ? control : null;
activeList = list;
pressPoint = e.GetPosition(list);
pressed = dragSource is not null;
}
private static void OnMove(object sender, MouseEventArgs e)
{
if (!pressed || dragSource is null || activeList is null || e.LeftButton != MouseButtonState.Pressed)
{
return;
}
var delta = e.GetPosition(activeList) - pressPoint;
if (Math.Abs(delta.X) < DragThreshold && Math.Abs(delta.Y) < DragThreshold)
{
return;
}
pressed = false;
DragDrop.DoDragDrop(activeList, new DataObject(typeof(ControlViewModel), dragSource), DragDropEffects.Move);
ClearAdorner();
dragSource = null;
}
private static void OnRelease(object sender, MouseButtonEventArgs e) => pressed = false;
/// <summary>Esc 로 드래그 취소 — 삽입선도 함께 걷는다</summary>
private static void OnQueryContinue(object sender, QueryContinueDragEventArgs e)
{
if (e.EscapePressed)
{
ClearAdorner();
}
}
private static void OnDragOver(object sender, DragEventArgs e)
{
if (sender is not ListBox list || !e.Data.GetDataPresent(typeof(ControlViewModel)))
{
return;
}
var (target, above) = ResolveTarget(list, e);
if (target is null)
{
ClearAdorner();
e.Effects = DragDropEffects.None;
e.Handled = true;
return;
}
e.Effects = DragDropEffects.Move;
e.Handled = true;
ShowAdorner(list, target, above);
}
private static void OnDragLeave(object sender, DragEventArgs e) => ClearAdorner();
private static void OnDrop(object sender, DragEventArgs e)
{
ClearAdorner();
if (sender is not ListBox list
|| GetDesigner(list) is not { } designer
|| e.Data.GetData(typeof(ControlViewModel)) is not ControlViewModel source)
{
return;
}
var (targetItem, above) = ResolveTarget(list, e);
if (targetItem?.DataContext is not ControlViewModel target
|| ReferenceEquals(target, source)
|| target.Parent is not null)
{
return;
}
designer.MoveControlNextTo(source, target, above);
e.Handled = true;
}
/// <summary>커서 아래의 드롭 대상 행과 위/아래 판정</summary>
private static (ListBoxItem? Item, bool Above) ResolveTarget(ListBox list, DragEventArgs e)
{
var hit = list.InputHitTest(e.GetPosition(list)) as DependencyObject;
var item = hit is null ? null : FindAncestor<ListBoxItem>(hit);
if (item?.DataContext is not ControlViewModel control || control.Parent is not null)
{
return (null, false);
}
var local = e.GetPosition(item);
return (item, local.Y < item.ActualHeight / 2);
}
private static void ShowAdorner(ListBox list, ListBoxItem item, bool above)
{
var layer = AdornerLayer.GetAdornerLayer(list);
if (layer is null)
{
return;
}
if (adorner is not null && ReferenceEquals(adorner.AdornedElement, item) && adorner.Above == above)
{
return;
}
ClearAdorner();
adorner = new InsertionAdorner(item, above);
layer.Add(adorner);
}
private static void ClearAdorner()
{
if (adorner is null)
{
return;
}
AdornerLayer.GetAdornerLayer(adorner.AdornedElement)?.Remove(adorner);
adorner = null;
}
private static object? RowDataOf(object? source)
=> source is DependencyObject node ? FindAncestor<ListBoxItem>(node)?.DataContext : null;
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 Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(node)
: LogicalTreeHelper.GetParent(node);
}
return null;
}
#endregion
#region Types
/// <summary>
/// 드롭 위치 삽입선 — 대상 행의 위 또는 아래 모서리에 2px 선을 그린다.
///
/// [200] 은 대상 행의 BorderThickness 를 0→2 로 올려 표시하는데, 그러면 행 내용이 2px 밀려
/// 드래그 중 목록이 들썩인다. 어도너는 레이아웃 바깥에 그려져 그런 흔들림이 없다.
/// </summary>
private sealed class InsertionAdorner : Adorner
{
private static readonly Pen Line = CreatePen();
public bool Above { get; }
public InsertionAdorner(UIElement adorned, bool above) : base(adorned)
{
Above = above;
IsHitTestVisible = false;
}
protected override void OnRender(DrawingContext drawingContext)
{
var width = ((FrameworkElement)AdornedElement).ActualWidth;
var height = ((FrameworkElement)AdornedElement).ActualHeight;
var y = Above ? 1 : height - 1;
drawingContext.DrawLine(Line, new Point(0, y), new Point(width, y));
}
private static Pen CreatePen()
{
// 어도너는 리소스 조회 컨텍스트 밖이라 DynamicResource 가 닿지 않는다.
// 브랜드색은 양 테마 공용값(B.Accent)이라 여기서만 상수로 둔다.
var brush = new SolidColorBrush(Color.FromRgb(0x00, 0x99, 0xFF));
brush.Freeze();
var pen = new Pen(brush, 2);
pen.Freeze();
return pen;
}
}
#endregion
}