레이어 패널을 [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:
co-authored by
Claude Opus 5
parent
a30421f9b5
commit
76584e5004
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user