레이어 패널을 [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
}
@@ -5,7 +5,7 @@ using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 레이어 목록 행 템플릿 선택 — 그룹 폴더 행과 컨트롤 행을 가른다.
/// 레이어 목록 행 템플릿 선택 — 페이지 헤더 / 그룹 폴더 / 컨트롤 / '페이지 추가' 네 종류를 가른다.
///
/// <c>DataTemplate DataType</c> 암시 매칭을 쓸 수 없다: WPF 는 <b>정확한 런타임 형</b>으로만 찾는데
/// 컨트롤 행의 실제 형은 <c>LabelViewModel</c>·<c>TextBoxViewModel</c> 처럼 제각각이고
@@ -13,12 +13,23 @@ namespace SheetMe.Designer.Controls;
/// </summary>
public sealed class LayerRowTemplateSelector : DataTemplateSelector
{
/// <summary>페이지 헤더 행</summary>
public DataTemplate? PageTemplate { get; set; }
/// <summary>그룹 폴더 행</summary>
public DataTemplate? GroupTemplate { get; set; }
/// <summary>컨트롤 행</summary>
public DataTemplate? ControlTemplate { get; set; }
public override DataTemplate? SelectTemplate(object? item, DependencyObject container)
=> item is LayerGroupViewModel ? GroupTemplate : ControlTemplate;
/// <summary>맨 아래 '페이지 추가' 행</summary>
public DataTemplate? AddPageTemplate { get; set; }
public override DataTemplate? SelectTemplate(object? item, DependencyObject container) => item switch
{
LayerPageViewModel => PageTemplate,
LayerGroupViewModel => GroupTemplate,
LayerAddPageViewModel => AddPageTemplate,
_ => ControlTemplate,
};
}
@@ -93,6 +93,35 @@ public sealed class TypeToIconConverter : IValueConverter
=> throw new NotSupportedException();
}
/// <summary>
/// <c>"아이콘명|크기|브러시키"</c> 한 문자열 → Lucide 아이콘 비주얼.
///
/// 색이 <b>행 상태에 따라 달라져야</b> 할 때 쓴다. 보통은 색을 ConverterParameter 에 적지만
/// ConverterParameter 는 바인딩할 수 없어서, 색이 변하는 순간 아이콘이 이전 색에 굳어 버린다
/// (선택 밴드 위에서 파란 아이콘이 사라지는 식). 그래서 사양 전체를 값 쪽으로 옮겼다.
/// </summary>
public sealed class IconSpecConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string spec)
{
return null;
}
var first = spec.IndexOf('|');
if (first < 0)
{
return LucideIcons.Icon(spec, 14, "B.Muted");
}
var name = spec[..first];
var (size, brushKey) = IconParam.Parse(spec[(first + 1)..], 14);
return LucideIcons.Icon(name, size, brushKey);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
/// <summary>팔레트 타입명 → 카테고리명 컨버터(CollectionViewSource 그룹핑용)</summary>
public sealed class TypeToCategoryConverter : IValueConverter
{
@@ -128,9 +128,68 @@ public static class DialogShots
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
designer.LayerTypeCandidates(), new[] { "Label" })),
// 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다.
// 강조는 IsSelected 가 아니라 Highlight 3상태가 그리므로 눈으로 확인할 경로가 이것뿐이다.
("14-layer-plain", () => HostLayerPanel(designer, LayerShotMode.None)),
("15-layer-selected", () => HostLayerPanel(designer, LayerShotMode.Single)),
("16-layer-group", () => HostLayerPanel(designer, LayerShotMode.Group)),
};
}
/// <summary>레이어 패널 스냅숏의 선택 상태</summary>
private enum LayerShotMode
{
/// <summary>선택 없음</summary>
None,
/// <summary>컨트롤 하나 선택 — 파란 밴드 하나</summary>
Single,
/// <summary>그룹 전체 선택 — 폴더 파랑 + 멤버 서브트리, 이어진 블록</summary>
Group,
}
/// <summary>
/// 레이어 패널을 실제 패널 폭(255px)의 창에 담는다.
/// MainViewModel 을 통째로 만들면 DB 조회가 딸려 오므로, 패널이 읽는 <c>CurrentDesigner</c> 하나만
/// 들고 있는 최소 DataContext 를 세워 준다.
/// </summary>
private static Window HostLayerPanel(DesignerViewModel designer, LayerShotMode mode)
{
designer.Selection.Clear();
designer.SetLayerTypeFilter(Array.Empty<string>());
designer.LayerFilter = string.Empty;
var page = designer.Pages[0];
if (mode != LayerShotMode.None && page.Controls.Count >= 3)
{
designer.Selection.Set(new[] { page.Controls[^1], page.Controls[^2] });
designer.GroupSelection();
if (mode == LayerShotMode.Single)
{
designer.Selection.SetSingle(page.Controls[0]);
}
}
designer.RebuildLayerRows();
return new Window
{
Title = $"레이어 — {mode}",
Width = 255,
Height = 620,
Content = new Views.LayerPanelView { DataContext = new LayerShotContext(designer) },
Background = Application.Current.TryFindResource("B.Panel") as Brush,
};
}
/// <summary>패널이 바인딩하는 <c>CurrentDesigner</c> 만 노출하는 스냅숏 전용 DataContext</summary>
private sealed class LayerShotContext
{
public LayerShotContext(DesignerViewModel designer) => CurrentDesigner = designer;
public DesignerViewModel CurrentDesigner { get; }
}
/// <summary>인스펙터를 실제 패널 폭(300px)의 창에 담는다 — 컨트롤 하나를 선택한 상태로</summary>
private static Window HostInspector(DesignerViewModel designer, string controlId,
InspectorTab tab = InspectorTab.Design)
+99 -1
View File
@@ -279,12 +279,29 @@ public static class EditSmoke
groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"),
$"실제 {groupRow?.Members.Count} / {groupRow?.Name}");
// 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다
Check("레이어: 첫 행은 페이지 헤더", lay.LayerRows.FirstOrDefault() is LayerPageViewModel);
Check("레이어: 마지막 행은 페이지 추가", lay.LayerRows.LastOrDefault() is LayerAddPageViewModel);
Check("레이어: 페이지 헤더는 선택 대상 아님",
lay.LayerRows.OfType<ILayerRow>().Where(r => r is LayerPageViewModel or LayerAddPageViewModel)
.All(r => !r.IsSelectableRow));
// 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스)
var folderIndex = lay.LayerRows.IndexOf(groupRow!);
Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐",
folderIndex >= 0
&& lay.LayerRows.Skip(folderIndex + 1).Take(2)
.OfType<ControlViewModel>().Count(c => c.LayerIndent == 20) == 2);
.OfType<ControlViewModel>().Count(c => c.LayerIndent == 32) == 2);
Check("레이어: 페이지 직속 컨트롤 들여쓰기 12",
lay.LayerRows.OfType<ControlViewModel>()
.Where(c => c.Model.GroupId is null && c.Parent is null)
.All(c => c.LayerIndent == 12));
// 목록 맨 위 = z-order 최상위 (모델 리스트 index 0)
var topControl = lay.LayerRows.OfType<ControlViewModel>().First();
Check("레이어: 목록 맨 위가 z-order 최상위",
ReferenceEquals(lay.Document.Pages[0].Controls[0], topControl.Model),
$"실제 맨 위 {topControl.Id} / 모델 첫째 {lay.Document.Pages[0].Controls[0].Id}");
// 접으면 멤버 행이 빠지고, 펴면 되돌아온다
var expandedCount = lay.LayerRows.Count;
@@ -371,6 +388,87 @@ public static class EditSmoke
lay.GroupSelection();
Check("레이어: 그룹은 XML 에 나가지 않음",
!serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase));
// 강조 3상태 — 그룹 전체 선택이면 폴더=Blue, 멤버=Navy 이고 연속 블록으로 이어진다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
lay.Selection.Set(groupRow.Members.ToList(), groupRow.Members[0]);
lay.UpdateLayerHighlight();
Check("레이어: 그룹 전원 선택 → 폴더 Blue", groupRow.Highlight == LayerHighlight.Blue,
$"실제 {groupRow.Highlight}");
Check("레이어: 그룹 전원 선택 → 멤버 Navy",
groupRow.Members.All(m => m.Highlight == LayerHighlight.Navy));
Check("레이어: 폴더와 멤버가 이어진 블록",
groupRow.MergeBottom && groupRow.Members[0].MergeTop);
lay.Selection.SetSingle(groupRow.Members[0]);
lay.UpdateLayerHighlight();
Check("레이어: 멤버 하나만 선택 → 그 행만 Blue, 폴더 무강조",
groupRow.Members[0].Highlight == LayerHighlight.Blue
&& groupRow.Highlight == LayerHighlight.None);
// 강조 계산이 페이지 헤더에서 끊긴다(블록이 페이지를 넘지 않는다)
Check("레이어: 페이지 헤더는 항상 무강조",
lay.LayerRows.OfType<LayerPageViewModel>().All(p => p.Highlight == LayerHighlight.None));
// 페이지 복제 — 컨트롤 이름 유일성과 그룹 분리가 지켜져야 한다
var beforePages = lay.Pages.Count;
var beforeIds = lay.Pages[0].Controls.Select(c => c.Id).ToHashSet(StringComparer.Ordinal);
var copied = lay.DuplicatePage(lay.Pages[0]);
Check("레이어: 페이지 복제 — 장수 +1", lay.Pages.Count == beforePages + 1 && copied is not null);
if (copied is not null)
{
Check("레이어: 페이지 복제 — 컨트롤 수 동일",
copied.Controls.Count == lay.Pages[0].Controls.Count);
Check("레이어: 페이지 복제 — 이름이 전부 새로 발급됨",
copied.Controls.All(c => !beforeIds.Contains(c.Id)));
Check("레이어: 페이지 복제 — 그룹이 원본과 분리됨",
copied.Controls.Where(c => c.Model.GroupId is not null)
.All(c => lay.Pages[0].Controls.All(o => o.Model.GroupId != c.Model.GroupId)));
}
Check("레이어: 전 페이지가 아웃라인에 나옴",
lay.LayerRows.OfType<LayerPageViewModel>().Count() == lay.Pages.Count);
// 페이지 접기 — 그 페이지 컨트롤 행만 빠진다
var pageHeader = lay.LayerRows.OfType<LayerPageViewModel>().First();
var beforeCollapse = lay.LayerRows.Count;
pageHeader.IsExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 페이지 접기 — 행 감소", lay.LayerRows.Count < beforeCollapse);
Check("레이어: 페이지 접어도 헤더는 남음",
lay.LayerRows.OfType<LayerPageViewModel>().Count() == lay.Pages.Count);
lay.LayerRows.OfType<LayerPageViewModel>().First().IsExpanded = true;
lay.RebuildLayerRows();
Check("레이어: 페이지 펴기 — 행 복귀", lay.LayerRows.Count == beforeCollapse);
// 드래그 재정렬 — 목록에서 아래 행을 맨 위 행 '위'로 옮기면 z-order 최상위가 된다
var order = lay.LayerRows.OfType<ControlViewModel>()
.Where(c => c.Parent is null && ReferenceEquals(lay.PageOf(c), lay.Pages[0])).ToList();
if (order.Count >= 2)
{
var mover = order[^1];
var anchor = order[0];
lay.MoveControlNextTo(mover, anchor, above: true);
Check("레이어: 드래그로 맨 위에 놓으면 z-order 최상위",
ReferenceEquals(lay.Pages[0].Model.Controls[0], mover.Model),
$"실제 {lay.Pages[0].Model.Controls[0].Id} 기대 {mover.Id}");
Check("레이어: 재정렬이 되돌리기 스텝", lay.Undo.CanUndo);
}
// 눈·자물쇠도 되돌릴 수 있어야 한다
var flagTarget = lay.LayerRows.OfType<ControlViewModel>().First();
lay.Undo.Snapshot();
var depthBefore = lay.Undo.CanUndo;
flagTarget.IsLockedFlag = true;
lay.Undo.Undo();
Check("레이어: 잠금 토글이 Undo 로 원복",
lay.Pages.SelectMany(p => p.Controls).All(c => !c.Model.Locked) && depthBefore);
// 데이터 배선 배지
var badgeTarget = lay.Pages[0].Controls[0];
Check("레이어: 배선 없는 컨트롤은 배지 없음", !badgeTarget.HasDataBinding);
badgeTarget.Model.Props.SetText("DataTableField", "DT.Item(\"ChtNum\")");
Check("레이어: DataTableField 있으면 배지", badgeTarget.HasDataBinding);
badgeTarget.Model.Props.SetText("DataTableField", string.Empty);
}
// 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유)
@@ -682,6 +682,78 @@
</Style.Triggers>
</Style>
<!--
레이어 목록 전용 행 컨테이너 — [200]SheetMe 아웃라인 룩.
공용 ListBoxItem 스타일을 쓰지 않는 이유가 셋 있다.
① 공용은 Margin 2,1 이 고정이라 위아래 행이 1px 씩 떨어져 '이어진 선택 블록'이 절대 안 만들어진다.
② 배경을 IsSelected 가 아니라 <b>Highlight 3상태</b>가 결정해야 한다 — 그룹 폴더(파랑)와
그 멤버(서브트리 배경)는 색이 다르면서도 하나의 블록이어야 하고, 페이지 행은 선택 자체가 없다.
③ 공용 스타일 안의 지역 TextBlock Foreground 지정은 '다크에서 문자열 Content 가 라이트 색으로
떨어지던 회귀'를 막는 장치라 뺄 수 없는데, 레이어 행은 전부 DataTemplate 이라 그 지역 지정이
오히려 선택 밴드 위 흰 글자를 막는다. 그래서 전용 스타일에서만 뺀다.
-->
<Style x:Key="LayerRowItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="{TemplateBinding Background}" CornerRadius="6"
SnapsToDevicePixels="True">
<ContentPresenter TextElement.Foreground="{TemplateBinding Foreground}" />
</Border>
<ControlTemplate.Triggers>
<!-- 호버는 무강조 행에만. 강조 밴드 위에 회색 호버가 겹치면 밴드가 탁해진다 -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Highlight}" Value="None" />
<Condition Binding="{Binding IsMouseOver, RelativeSource={RelativeSource Self}}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
</MultiDataTrigger>
<DataTrigger Binding="{Binding Highlight}" Value="Blue">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.LayerSel}" />
<Setter Property="Foreground" Value="{DynamicResource B.OnAccent}" />
</DataTrigger>
<DataTrigger Binding="{Binding Highlight}" Value="Navy">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.LayerSubtree}" />
</DataTrigger>
<!-- 연속 강조 행은 맞닿는 모서리를 각지게 하고 1px 겹쳐 하나의 덩어리로 만든다 -->
<DataTrigger Binding="{Binding MergeTop}" Value="True">
<Setter TargetName="bd" Property="CornerRadius" Value="0,0,6,6" />
</DataTrigger>
<DataTrigger Binding="{Binding MergeBottom}" Value="True">
<Setter TargetName="bd" Property="CornerRadius" Value="6,6,0,0" />
<Setter TargetName="bd" Property="Margin" Value="0,0,0,-1" />
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding MergeTop}" Value="True" />
<Condition Binding="{Binding MergeBottom}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter TargetName="bd" Property="CornerRadius" Value="0" />
<Setter TargetName="bd" Property="Margin" Value="0,0,0,-1" />
</MultiDataTrigger>
<!-- 페이지 행·'페이지 추가' 행은 선택 대상이 아니다(Shift 범위에 끼어들면 안 된다) -->
<DataTrigger Binding="{Binding IsSelectableRow}" Value="False">
<Setter Property="Focusable" Value="False" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!--
접기/펼치기 셰브론 — 켜짐(=펼침)이 기본 상태라 IsChecked 채움이 들어가면 항상 눌린 것처럼 보인다.
방향이 이미 상태를 말해 주므로 채움은 빼고 호버 반응만 남긴다.
@@ -18,6 +18,16 @@
<!-- 강조 채움(Primary 버튼) — 흰 글자를 얹으므로 양 테마 동일하게 짙은 값(5.19:1).
브랜드색 #0099FF 위 흰 글자는 3.0:1 로 본문 기준 미달이다. -->
<SolidColorBrush x:Key="B.AccentFill" Color="#0F6FBF" />
<!--
레이어 목록 전용 3종 — 선택 행이 '채워진 파란 밴드'가 되는 [200] 룩.
브랜드색 #0099FF 를 그대로 채우면 흰 글자가 3.00:1(본문 4.5:1 미달)이라
B.AccentFill 과 같은 짙은 값을 쓴다. 키를 나눠 둔 건 나중에 갈라칠 여지를 남기려는 것.
-->
<SolidColorBrush x:Key="B.LayerSel" Color="#0F6FBF" />
<!-- 그룹 전원 선택 시 멤버 행 배경. [200] 원본 #14283A 는 B.Hover 대비 1.03:1 로 밴드가 안 읽힌다 -->
<SolidColorBrush x:Key="B.LayerSubtree" Color="#16304A" />
<!-- 선택 밴드 위 보조 텍스트(컨트롤 이름 둘째 줄·멤버 수). B.Muted 는 #0F6FBF 위 1.75:1 로 사라진다 -->
<SolidColorBrush x:Key="B.OnSelMuted" Color="#E6F1FB" />
<SolidColorBrush x:Key="B.Accent2" Color="#2A2A2A" />
<SolidColorBrush x:Key="B.Sel" Color="#3C3C3C" /><!-- 선택/체크 강조 채움(입력칩 위에서 또렷) -->
<SolidColorBrush x:Key="B.Hover" Color="#242424" />
@@ -29,6 +29,18 @@
<SolidColorBrush x:Key="B.AccentText" Color="#0F6FBF" />
<!-- 강조 채움(Primary 버튼) — 흰 글자를 얹으므로 5.19:1. 양 테마 동일값. -->
<SolidColorBrush x:Key="B.AccentFill" Color="#0F6FBF" />
<!--
레이어 목록 전용 3종 — 다크 대응본 주석 참조.
선택 밴드는 흰 글자를 얹으므로 양 테마 동일한 짙은 값(#0F6FBF, 흰 패널 위 5.19:1).
-->
<SolidColorBrush x:Key="B.LayerSel" Color="#0F6FBF" />
<!--
그룹 전원 선택 시 멤버 행 배경. [200] 원본 #E4F2FF 는 흰 패널 위 1.14:1 로 밴드가 안 읽히고,
같은 hex 가 B.Accent2 에서 이미 기각됐다. #D3E7FA 는 1.26:1 — 옆 파란 밴드와 이어져 보이는
선에서 존재는 드러난다.
-->
<SolidColorBrush x:Key="B.LayerSubtree" Color="#D3E7FA" />
<SolidColorBrush x:Key="B.OnSelMuted" Color="#E6F1FB" />
<SolidColorBrush x:Key="B.Accent2" Color="#CFE7FF" /><!-- 활성 틴트 — 흰 바 위 1.14:1 로는 켜짐이 안 읽혀 진하게 -->
<SolidColorBrush x:Key="B.Sel" Color="#C2E2FF" /><!-- 선택 채움 — 흰 배경 1.19:1 은 선택 여부가 안 보인다 -->
<SolidColorBrush x:Key="B.Hover" Color="#EAEAEA" />
@@ -10,13 +10,16 @@ namespace SheetMe.Designer.ViewModels.Controls;
/// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다.
/// 좌표계: WinForms px = WPF DIP 1:1.
/// </summary>
public abstract class ControlViewModel : ViewModelBase
public abstract class ControlViewModel : ViewModelBase, ILayerRow
{
#region Member Fields
private bool isSelected;
private bool isHovered;
private double layerIndent;
private bool isLayerExpanded = true;
private LayerHighlight layerHighlight;
private bool layerMergeTop;
private bool layerMergeBottom;
#endregion
#region Properties
@@ -107,15 +110,27 @@ public abstract class ControlViewModel : ViewModelBase
/// <summary>상속 기점 부모 글자색(재해석용 보관)</summary>
public Brush ParentForeground { get; private set; } = Brushes.Black;
/// <summary>
/// 잠금·숨김 토글 직전에 되돌리기 지점을 남기는 콜백 — 평탄화 때 디자이너가 배선한다.
/// (VM 은 UndoService 를 모르고, 알게 하면 모델 투영이라는 역할이 흐려진다)
/// </summary>
public Action? LayerFlagChanging { get; set; }
/// <summary>잠금 플래그(레이어 패널 토글)</summary>
public bool IsLockedFlag
{
get => Model.Locked;
set
{
if (Model.Locked == value)
{
return;
}
LayerFlagChanging?.Invoke();
Model.Locked = value;
OnPropertyChanged(nameof(IsLockedFlag));
OnPropertyChanged(nameof(LockIcon));
OnPropertyChanged(nameof(LockIconSpec));
}
}
@@ -125,9 +140,15 @@ public abstract class ControlViewModel : ViewModelBase
get => Model.Hidden;
set
{
if (Model.Hidden == value)
{
return;
}
LayerFlagChanging?.Invoke();
Model.Hidden = value;
OnPropertyChanged(nameof(IsHiddenFlag));
OnPropertyChanged(nameof(HiddenIcon));
OnPropertyChanged(nameof(HiddenIconSpec));
OnPropertyChanged(nameof(DesignOpacity));
}
}
@@ -175,6 +196,21 @@ public abstract class ControlViewModel : ViewModelBase
/// <summary>레이어 숨김 토글 아이콘명</summary>
public string HiddenIcon => Model.Hidden ? "eye-off" : "eye";
/// <summary>
/// 강조 밴드 위 아이콘 색 — 밴드는 짙은 파랑 채움이라 보조색(B.Muted)을 그대로 두면
/// 눈·자물쇠가 1.6:1 로 묻힌다. 선택한 행에서 잠금 상태가 안 보이면 배지의 의미가 없다.
/// </summary>
private string RowIconKey => layerHighlight == LayerHighlight.Blue ? "B.OnAccent" : "B.Muted";
/// <summary>잠금 토글 아이콘 사양</summary>
public string LockIconSpec => $"{LockIcon}|13|{RowIconKey}";
/// <summary>숨김 토글 아이콘 사양</summary>
public string HiddenIconSpec => $"{HiddenIcon}|13|{RowIconKey}";
/// <summary>컨테이너 자식 접기 셰브론 사양</summary>
public string LayerChevronSpec => $"{LayerChevronIcon}|12|{RowIconKey}";
/// <summary>
/// 레이어 목록 들여쓰기(px) — 그룹 멤버·컨테이너 자식의 계층을 나타낸다.
/// 목록이 평탄한 ListBox 라(TreeView 아님) 깊이를 여백으로만 표현한다 — 평탄화 시 대입된다.
@@ -197,6 +233,7 @@ public abstract class ControlViewModel : ViewModelBase
if (SetProperty(ref isLayerExpanded, value))
{
OnPropertyChanged(nameof(LayerChevronIcon));
OnPropertyChanged(nameof(LayerChevronSpec));
LayerExpandedChanged?.Invoke();
}
}
@@ -207,6 +244,62 @@ public abstract class ControlViewModel : ViewModelBase
/// <summary>펼침 셰브론 아이콘명</summary>
public string LayerChevronIcon => isLayerExpanded ? "chevron-down" : "chevron-right";
/// <summary>
/// 데이터 배선 배지 — 이 컨트롤이 조회/데이터소스와 엮여 있는가.
///
/// 판정 기준을 새로 만들지 않고 인스펙터의 '데이터' 탭 분류와 같은 세 속성을 쓴다.
/// 목록에서 "값이 어디서 오는 컨트롤인지"를 한눈에 가려내려는 표시이지 편집 대상이 아니다.
/// </summary>
public bool HasDataBinding =>
!string.IsNullOrWhiteSpace(Prop("DataInterfaceTag"))
|| !string.IsNullOrWhiteSpace(Prop("DataTableField"))
|| !string.IsNullOrWhiteSpace(Prop("Query"));
/// <summary>
/// 레이어 행 타입 아이콘 사양 — 강조 밴드 위에서는 아이콘 색도 함께 뒤집는다.
/// 색을 ConverterParameter 에 두면 바인딩이 안 돼 선택 순간 아이콘이 밴드에 묻힌다.
/// </summary>
public string LayerIconSpec =>
$"{SheetMe.Designer.Controls.PaletteIconCatalog.IconOf(Type)}|14|" + layerHighlight switch
{
LayerHighlight.Blue => "B.OnAccent",
LayerHighlight.Navy => "B.AccentText",
_ => "B.Accent",
};
#endregion
#region ILayerRow
/// <summary>레이어 행 강조 상태</summary>
public LayerHighlight Highlight => layerHighlight;
/// <summary>위 행과 이어진 강조 블록인가</summary>
public bool MergeTop => layerMergeTop;
/// <summary>아래 행과 이어진 강조 블록인가</summary>
public bool MergeBottom => layerMergeBottom;
/// <summary>컨트롤 행은 언제나 선택 대상</summary>
public bool IsSelectableRow => true;
/// <summary>강조 상태 적용 — 디자이너가 선택이 바뀔 때마다 전 행을 한 번에 계산해 밀어 넣는다</summary>
public void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom)
{
var highlightChanged = layerHighlight != highlight;
layerHighlight = highlight;
layerMergeTop = mergeTop;
layerMergeBottom = mergeBottom;
OnPropertyChanged(nameof(Highlight));
OnPropertyChanged(nameof(MergeTop));
OnPropertyChanged(nameof(MergeBottom));
if (highlightChanged)
{
OnPropertyChanged(nameof(LayerIconSpec));
OnPropertyChanged(nameof(LockIconSpec));
OnPropertyChanged(nameof(HiddenIconSpec));
OnPropertyChanged(nameof(LayerChevronSpec));
}
}
#endregion
#region Constructors
@@ -76,8 +76,9 @@ public sealed class DesignerViewModel : ViewModelBase
private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns;
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
private readonly HashSet<PageViewModel> collapsedPages = new();
private readonly HashSet<string> layerTypeFilter = new(StringComparer.Ordinal);
private System.Collections.Specialized.INotifyCollectionChanged? watchedControls;
private readonly List<System.Collections.Specialized.INotifyCollectionChanged> watchedControls = new();
#endregion
#region Properties
@@ -151,27 +152,31 @@ public sealed class DesignerViewModel : ViewModelBase
{
Selection.ActivePage = value;
}
WatchLayerSource(value);
RebuildLayerRows();
// 아웃라인이 전 페이지를 담으므로 목록 자체는 그대로다 — 활성 표시만 갱신한다
foreach (var header in LayerRows.OfType<LayerPageViewModel>())
{
header.NotifyStateChanged();
}
}
}
}
/// <summary>
/// 활성 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
/// 문서 전 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
/// (예전에는 <c>page.Controls</c> 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만,
/// 그룹 폴더가 끼면서 목록이 평탄화 결과가 됐다 — 원본이 변할 때 다시 만들어야 한다.)
/// 아웃라인이 평탄화 결과가 된 뒤로는 원본이 변할 때 다시 만들어야 한다.)
/// </summary>
private void WatchLayerSource(PageViewModel? page)
private void WatchLayerSource()
{
if (watchedControls is not null)
foreach (var watched in watchedControls)
{
watchedControls.CollectionChanged -= OnLayerSourceChanged;
watched.CollectionChanged -= OnLayerSourceChanged;
}
watchedControls = page?.Controls;
if (watchedControls is not null)
watchedControls.Clear();
foreach (var page in Pages)
{
watchedControls.CollectionChanged += OnLayerSourceChanged;
page.Controls.CollectionChanged += OnLayerSourceChanged;
watchedControls.Add(page.Controls);
}
}
@@ -204,13 +209,16 @@ public sealed class DesignerViewModel : ViewModelBase
/// </summary>
public ObservableCollection<object> LayerRows { get; } = new();
/// <summary>검색·필터 결과가 0행인지 — 빈 목록에 안내를 띄우기 위한 플래그</summary>
public bool IsLayerListEmpty => LayerRows.Count == 0;
/// <summary>
/// 컨트롤 행이 하나도 없는지 — 빈 목록에 안내를 띄우기 위한 플래그.
/// 페이지 헤더와 '페이지 추가' 행은 늘 있으므로 행 수가 아니라 컨트롤 행 유무로 판단한다.
/// </summary>
public bool IsLayerListEmpty => !LayerRows.OfType<Controls.ControlViewModel>().Any();
/// <summary>레이어 목록이 비어 보이는 이유</summary>
/// <summary>레이어 목록이 비어 보이는 이유 — 걸러진 것인지, 애초에 없는 것인지</summary>
public string LayerEmptyText =>
SelectedPage is null || SelectedPage.Controls.Count == 0
? "이 페이지에는 컨트롤이 없습니다"
Pages.All(p => p.Controls.Count == 0)
? "아직 컨트롤이 없습니다"
: "검색·필터에 맞는 항목이 없습니다";
/// <summary>타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단</summary>
@@ -382,7 +390,9 @@ public sealed class DesignerViewModel : ViewModelBase
OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay();
Inspector.Rebuild();
// SelectedPage 대입이 이미 목록을 만들지만, 페이지가 0개면 setter 가 발화하지 않는
// 페이지 인스턴스가 통째로 새로 만들어졌다 — 구독과 접힘 상태를 갈아끼우고 목록을 다시 만든
collapsedPages.Clear();
WatchLayerSource();
RebuildLayerRows();
}
@@ -395,6 +405,59 @@ public sealed class DesignerViewModel : ViewModelBase
SelectedPage = Pages.LastOrDefault();
}
/// <summary>
/// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다.
///
/// 컨트롤 이름(Id)은 <b>문서 안에서 유일해야 한다</b> — 액션 대상·입력 파라미터가 이름으로 서로를
/// 가리키고 CalcBox 수식도 이름으로 합산하므로, 같은 이름이 두 페이지에 있으면 런타임이 어느 쪽을
/// 잡을지 알 수 없다. 그래서 붙여넣기와 같은 경로로 전부 새 이름을 발급한다.
/// 그룹도 복제본끼리 다시 묶는다(원본 그룹에 끼면 한쪽을 클릭할 때 반대편까지 딸려 온다).
/// </summary>
public PageViewModel? DuplicatePage(PageViewModel? source)
{
var target = source ?? SelectedPage;
if (target is null)
{
return null;
}
Undo.Snapshot();
var clone = target.Model.Clone();
var usedIds = IdGenerator.CollectUsed(document);
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var element in clone.Controls)
{
AssignNewIds(element, usedIds);
RemapGroupId(element, groupRemap);
}
var index = document.Pages.IndexOf(target.Model);
document.Pages.Insert(index < 0 ? document.Pages.Count : index + 1, clone);
RebuildFromDocument();
var created = Pages.FirstOrDefault(p => ReferenceEquals(p.Model, clone));
if (created is not null)
{
SelectedPage = created;
}
return created;
}
/// <summary>페이지 삭제(확인 후) — 최소 1페이지 유지</summary>
public void RemovePage(PageViewModel? page)
{
var target = page ?? SelectedPage;
if (target is null || document.Pages.Count <= 1)
{
return;
}
if (MessageBox.Show($"페이지 {target.Index + 1}을(를) 삭제할까요? (컨트롤 {target.Controls.Count}개 포함)",
"페이지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
{
return;
}
Undo.Snapshot();
document.Pages.Remove(target.Model);
RebuildFromDocument();
}
/// <summary>선택 페이지 삭제(확인 후) — 최소 1페이지 유지</summary>
public void RemoveSelectedPage()
{
@@ -1165,6 +1228,87 @@ public sealed class DesignerViewModel : ViewModelBase
RefreshOverlay();
}
/// <summary>
/// 같은 부모 안에서 임의 위치로 z-order 이동 — 레이어 목록 드래그 재정렬의 모델 연산.
///
/// <see cref="ReorderSelection"/> 은 맨 앞/맨 뒤 두 극단뿐이고 컨테이너 자식은 아예 건너뛴다.
/// 여기서는 최상위든 컨테이너 자식이든 제 부모 컬렉션 안에서 원하는 자리로 옮긴다.
/// 부모가 다른 컨트롤 사이의 이동(재부모화)은 하지 않는다 — 좌표계가 바뀌는 별개의 연산이다.
///
/// 좌표계 주의: VM 컬렉션은 <b>그리기 순서</b>(마지막이 최상위), 모델은 <b>XML 순서</b>(index 0 이
/// 최상위)로 서로 역순이다. 두 리스트를 같이 옮기되 index 를 뒤집어 대응시킨다.
/// </summary>
/// <param name="viewModel">옮길 컨트롤</param>
/// <param name="newDrawIndex">목표 위치(부모의 VM 컬렉션 = 그리기 순서 기준 index)</param>
/// <returns>실제로 순서가 바뀌었으면 true</returns>
public bool MoveWithinParent(ControlViewModel viewModel, int newDrawIndex)
{
var (viewModels, models) = ParentListsOf(viewModel);
if (viewModels is null || models is null)
{
return false;
}
var oldDrawIndex = viewModels.IndexOf(viewModel);
if (oldDrawIndex < 0)
{
return false;
}
var clamped = Math.Clamp(newDrawIndex, 0, viewModels.Count - 1);
if (clamped == oldDrawIndex)
{
return false;
}
Undo.Snapshot();
viewModels.RemoveAt(oldDrawIndex);
viewModels.Insert(clamped, viewModel);
models.Remove(viewModel.Model);
// 그리기 index → 모델 index (역순). Remove 후 개수가 하나 줄었으므로 Count 를 그대로 쓴다.
models.Insert(models.Count - clamped, viewModel.Model);
RefreshOverlay();
RebuildLayerRows();
return true;
}
/// <summary>
/// 레이어 목록 드래그 재정렬 — <paramref name="source"/> 를 <paramref name="target"/> 의
/// 위(<paramref name="above"/>=true) 또는 아래로 옮긴다.
///
/// 목록은 z-order 최상위가 맨 위인데 VM 컬렉션은 그리기 순서(마지막이 최상위)라 방향이 반대다.
/// 그래서 "목록에서 위" = "VM 컬렉션에서 뒤"로 뒤집어 계산한다.
/// </summary>
public bool MoveControlNextTo(ControlViewModel source, ControlViewModel target, bool above)
{
var (viewModels, _) = ParentListsOf(source);
if (viewModels is null || !viewModels.Contains(target) || ReferenceEquals(source, target))
{
return false;
}
var from = viewModels.IndexOf(source);
var targetIndex = viewModels.IndexOf(target);
// 목록 위 = 그리기 순서 뒤. 대상 '위'에 놓으려면 대상보다 뒤(=index 큼)로 가야 한다.
var to = above ? targetIndex + 1 : targetIndex;
// 자기 자신을 뺀 뒤의 자리로 보정
if (from < to)
{
to--;
}
return MoveWithinParent(source, to);
}
/// <summary>컨트롤이 속한 부모의 (VM 컬렉션, 모델 컬렉션) — 최상위면 페이지, 아니면 컨테이너</summary>
private (System.Collections.ObjectModel.ObservableCollection<ControlViewModel>? ViewModels, List<ControlElement>? Models)
ParentListsOf(ControlViewModel viewModel)
{
if (viewModel.Parent is ContainerViewModel container)
{
return (container.Children, container.Model.Children);
}
var page = PageOf(viewModel);
return page is null ? (null, null) : (page.Controls, page.Model.Controls);
}
/// <summary>방향키 넛지 — 400ms 코얼레스 Undo</summary>
public void NudgeSelection(double dx, double dy)
{
@@ -1296,17 +1440,23 @@ public sealed class DesignerViewModel : ViewModelBase
? name
: LayerGroupViewModel.FallbackName;
/// <summary>그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다</summary>
/// <summary>그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다(그 경로도 되돌릴 수 있어야 한다)</summary>
public void RenameGroup(string groupId, string? name)
{
var trimmed = (name ?? string.Empty).Trim();
if (trimmed.Length == 0 || trimmed == LayerGroupViewModel.FallbackName)
var current = document.GroupNames.TryGetValue(groupId, out var existing) ? existing : string.Empty;
var next = trimmed == LayerGroupViewModel.FallbackName ? string.Empty : trimmed;
if (string.Equals(current, next, StringComparison.Ordinal))
{
return;
}
Undo.Snapshot();
if (next.Length == 0)
{
document.GroupNames.Remove(groupId);
return;
}
Undo.Snapshot();
document.GroupNames[groupId] = trimmed;
document.GroupNames[groupId] = next;
}
/// <summary>그룹 펼침 상태 — 기본은 펼침</summary>
@@ -1397,29 +1547,25 @@ public sealed class DesignerViewModel : ViewModelBase
RebuildLayerRows();
}
/// <summary>활성 페이지에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순</summary>
/// <summary>문서에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순</summary>
public List<(string Type, int Count)> LayerTypeCandidates()
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
void Visit(IEnumerable<ControlViewModel> items)
foreach (var control in AllControls())
{
foreach (var item in items)
{
counts[item.Type] = counts.TryGetValue(item.Type, out var n) ? n + 1 : 1;
if (item is ContainerViewModel container)
{
Visit(container.Children);
}
}
counts[control.Type] = counts.TryGetValue(control.Type, out var n) ? n + 1 : 1;
}
Visit(SelectedPage?.Controls ?? Enumerable.Empty<ControlViewModel>());
return counts.OrderByDescending(p => p.Value).ThenBy(p => p.Key, StringComparer.Ordinal)
.Select(p => (p.Key, p.Value)).ToList();
}
/// <summary>
/// 레이어 목록 재구성 — 그룹 폴더 + 들여쓴 멤버 + 컨테이너 자식을 한 줄 시퀀스로 평탄화한다.
/// 순서는 페이지의 <b>그리기 순서</b>를 그대로 따른다(캔버스에서 뒤에 있는 것이 목록 위).
/// 레이어 목록 재구성 — <b>문서의 전 페이지</b>를 한 아웃라인으로 평탄화한다.
/// 페이지 헤더 → (펼쳐져 있으면) 그룹 폴더·컨트롤·컨테이너 자식 → 다음 페이지 → 맨 끝에 '페이지 추가'.
///
/// 순서는 <b>z-order 최상위가 맨 위</b>다(Figma·[200] 규약). PageViewModel.Controls 는 이미
/// 그리기 순서(마지막이 최상위)라 역순으로 훑는다 — 캔버스에서 위에 겹쳐 보이는 컨트롤을
/// 목록에서도 위에서 찾게 된다.
/// </summary>
public void RebuildLayerRows()
{
@@ -1427,11 +1573,17 @@ public sealed class DesignerViewModel : ViewModelBase
IsRebuildingLayers = true;
LayerRows.Clear();
var page = SelectedPage;
if (page is not null)
foreach (var page in Pages)
{
var header = new LayerPageViewModel(this, page);
LayerRows.Add(header);
if (!header.IsExpanded)
{
continue;
}
var rendered = new HashSet<string>(StringComparer.Ordinal);
foreach (var control in page.Controls)
foreach (var control in page.Controls.Reverse())
{
if (control.Model.GroupId is { } groupId)
{
@@ -1439,7 +1591,7 @@ public sealed class DesignerViewModel : ViewModelBase
{
continue;
}
var members = page.Controls.Where(c => c.Model.GroupId == groupId).ToList();
var members = page.Controls.Reverse().Where(c => c.Model.GroupId == groupId).ToList();
var shown = members.Where(PassesLayerFilter).ToList();
if (shown.Count == 0)
{
@@ -1451,24 +1603,93 @@ public sealed class DesignerViewModel : ViewModelBase
{
foreach (var member in shown)
{
AddLayerRow(member, 20);
AddLayerRow(member, GroupMemberIndent);
}
}
continue;
}
if (PassesLayerFilter(control))
{
AddLayerRow(control, 0);
AddLayerRow(control, PageChildIndent);
}
}
}
LayerRows.Add(new LayerAddPageViewModel());
IsRebuildingLayers = false;
UpdateLayerHighlight();
OnPropertyChanged(nameof(IsLayerListEmpty));
OnPropertyChanged(nameof(LayerEmptyText));
LayerRowsRebuilt?.Invoke();
}
/// <summary>페이지 직속 컨트롤·그룹 폴더의 들여쓰기 — 페이지 노드 아래라 항상 한 칸 들어간다</summary>
private const double PageChildIndent = 12;
/// <summary>그룹 멤버 들여쓰기 — 페이지 직속보다 한 단계 더</summary>
private const double GroupMemberIndent = 32;
/// <summary>컨테이너(패널/그룹박스) 자식의 추가 들여쓰기</summary>
private const double ContainerChildIndent = 16;
/// <summary>
/// 선택 강조 3상태 + 연속 블록 병합 계산 — 목록을 다시 만들지 않고 <b>색만</b> 갱신한다.
///
/// 선택이 바뀔 때마다 평탄화를 다시 돌리면 1,067행 서식에서 클릭 한 번에 목록 전체가 재생성된다.
/// 그래서 강조는 별도 경로로 분리했다.
///
/// 그룹 집계는 <b>전 페이지</b>를 대상으로 한다 — 그룹 식별자는 페이지를 넘나들 수 있고,
/// 폴더가 파랑이 되려면 "그 그룹의 모든 멤버"가 선택돼 있어야 한다.
/// </summary>
public void UpdateLayerHighlight()
{
var total = new Dictionary<string, int>(StringComparer.Ordinal);
var selected = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is not { } id)
{
continue;
}
total[id] = total.TryGetValue(id, out var t) ? t + 1 : 1;
if (control.IsSelected)
{
selected[id] = selected.TryGetValue(id, out var s) ? s + 1 : 1;
}
}
bool GroupFullySelected(string id)
=> total.TryGetValue(id, out var t) && t > 0
&& selected.TryGetValue(id, out var s) && s == t;
var states = new LayerHighlight[LayerRows.Count];
for (var i = 0; i < LayerRows.Count; i++)
{
states[i] = LayerRows[i] switch
{
// 그룹 전체가 선택되면 파랑은 폴더가 가져가고 멤버는 서브트리 배경으로 물러난다
ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy,
ControlViewModel c when c.IsSelected => LayerHighlight.Blue,
LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue,
_ => LayerHighlight.None,
};
}
for (var i = 0; i < LayerRows.Count; i++)
{
if (LayerRows[i] is not ILayerRow row)
{
continue;
}
// 병합 판정은 '색'이 아니라 '강조냐 아니냐'다 — 그래야 파랑 폴더와 네이비 멤버가
// 색만 다른 하나의 이어진 블록으로 보인다
var top = i > 0 && states[i] != LayerHighlight.None && states[i - 1] != LayerHighlight.None;
var bottom = i < LayerRows.Count - 1
&& states[i] != LayerHighlight.None && states[i + 1] != LayerHighlight.None;
row.ApplyHighlight(states[i], top, bottom);
}
}
/// <summary>
/// 목록 재구성 중 — 이때 ListBox 가 쏟아내는 선택 해제는 사용자의 조작이 아니다.
/// (<c>LayerRows.Clear()</c> 가 SelectedItems 를 비우면서 SelectionChanged 를 발화한다)
@@ -1483,14 +1704,18 @@ public sealed class DesignerViewModel : ViewModelBase
{
viewModel.LayerIndent = indent;
viewModel.LayerExpandedChanged ??= RequestLayerRebuild;
// 눈·자물쇠도 편집이다 — 실수로 서식 절반을 잠갔을 때 Ctrl+Z 로 돌아올 수 있어야 한다.
// 연속 토글은 400ms 안에서 한 스텝으로 묶는다(한 줄씩 잠그다 보면 Undo 스택이 금방 찬다).
viewModel.LayerFlagChanging ??= () => Undo.SnapshotForNudge();
LayerRows.Add(viewModel);
if (viewModel is not ContainerViewModel container || !viewModel.IsLayerExpanded)
{
return;
}
foreach (var child in container.Children.Where(PassesLayerFilter))
// 자식도 z-order 최상위가 위로 오게 역순
foreach (var child in container.Children.Reverse().Where(PassesLayerFilter))
{
AddLayerRow(child, indent + 16);
AddLayerRow(child, indent + ContainerChildIndent);
}
}
@@ -1518,16 +1743,17 @@ public sealed class DesignerViewModel : ViewModelBase
}
}
/// <summary>선택 집합이 바뀌었을 때 — 그룹 폴더의 눈·자물쇠 표시를 다시 계산</summary>
/// <summary>선택 집합이 바뀌었을 때 — 강조 3상태와 그룹 폴더의 눈·자물쇠 표시를 다시 계산</summary>
private void LayerSelectionChanged()
{
UpdateLayerHighlight();
foreach (var group in LayerRows.OfType<LayerGroupViewModel>())
{
group.NotifyFlagsChanged();
}
}
/// <summary>레이어 행(컨트롤 또는 그룹 폴더) → 선택 대상 컨트롤 목록</summary>
/// <summary>레이어 행 → 선택 대상 컨트롤 목록. 페이지 행·추가 행은 선택 대상이 아니라 빈 목록</summary>
public static IEnumerable<ControlViewModel> ControlsOfLayerRow(object? row) => row switch
{
ControlViewModel control => new[] { control },
@@ -1535,6 +1761,36 @@ public sealed class DesignerViewModel : ViewModelBase
_ => Enumerable.Empty<ControlViewModel>(),
};
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary>
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// <summary>페이지 펼침/접기</summary>
public void SetPageExpanded(PageViewModel page, bool expanded)
{
if (expanded ? collapsedPages.Remove(page) : collapsedPages.Add(page))
{
RequestLayerRebuild();
}
}
/// <summary>페이지 헤더 행 클릭 — 활성 페이지로 삼고 캔버스를 그 페이지로 옮긴다</summary>
public void ActivatePage(PageViewModel page)
{
if (!ReferenceEquals(SelectedPage, page))
{
SelectedPage = page;
}
foreach (var header in LayerRows.OfType<LayerPageViewModel>())
{
header.NotifyStateChanged();
}
ScrollToPageRequested?.Invoke(page);
}
/// <summary>캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다</summary>
public event Action<PageViewModel>? ScrollToPageRequested;
/// <summary>잘라내기 — 복사 후 삭제</summary>
public void CutSelection()
{
@@ -10,12 +10,15 @@ namespace SheetMe.Designer.ViewModels;
/// 새로 만들어진다 — 펼침 상태·이름은 여기에 두지 않고 각각
/// <see cref="DesignerViewModel"/> 의 접힌 그룹 집합과 <c>FormDocument.GroupNames</c> 가 소유한다.
/// </summary>
public sealed class LayerGroupViewModel : ViewModelBase
public sealed class LayerGroupViewModel : ViewModelBase, ILayerRow
{
#region Member Fields
private const string DefaultName = "그룹";
private readonly DesignerViewModel designer;
private bool isEditing;
private LayerHighlight highlight;
private bool mergeTop;
private bool mergeBottom;
#endregion
#region Properties
@@ -51,6 +54,7 @@ public sealed class LayerGroupViewModel : ViewModelBase
designer.SetGroupExpanded(GroupId, value);
OnPropertyChanged(nameof(IsExpanded));
OnPropertyChanged(nameof(ChevronIcon));
OnPropertyChanged(nameof(ChevronSpec));
}
}
@@ -94,6 +98,18 @@ public sealed class LayerGroupViewModel : ViewModelBase
/// <summary>숨김 아이콘명 — 하나라도 숨겨져 있으면 숨긴 모양</summary>
public string HiddenIcon => Members.Any(m => m.IsHiddenFlag) ? "eye-off" : "eye";
/// <summary>강조 밴드 위 아이콘 색 — 짙은 파랑 채움 위에서 보조색은 묻힌다</summary>
private string RowIconKey => highlight == LayerHighlight.Blue ? "B.OnAccent" : "B.Muted";
/// <summary>그룹 잠금 아이콘 사양</summary>
public string LockIconSpec => $"{LockIcon}|13|{RowIconKey}";
/// <summary>그룹 숨김 아이콘 사양</summary>
public string HiddenIconSpec => $"{HiddenIcon}|13|{RowIconKey}";
/// <summary>그룹 접기 셰브론 사양</summary>
public string ChevronSpec => $"{ChevronIcon}|12|{RowIconKey}";
/// <summary>이름 인라인 편집 중 — 더블클릭으로 켠다</summary>
public bool IsEditing
{
@@ -111,6 +127,47 @@ public sealed class LayerGroupViewModel : ViewModelBase
}
#endregion
#region ILayerRow
/// <summary>폴더 행 강조 — 멤버가 전부 선택됐을 때만 파랑</summary>
public LayerHighlight Highlight => highlight;
/// <summary>위 행과 이어진 강조 블록인가</summary>
public bool MergeTop => mergeTop;
/// <summary>아래 행과 이어진 강조 블록인가 — 폴더 바로 아래가 멤버라 보통 참이다</summary>
public bool MergeBottom => mergeBottom;
/// <summary>폴더 행도 클릭으로 그룹 전체를 고를 수 있다</summary>
public bool IsSelectableRow => true;
/// <summary>강조 상태 적용</summary>
public void ApplyHighlight(LayerHighlight value, bool top, bool bottom)
{
var changed = highlight != value;
highlight = value;
mergeTop = top;
mergeBottom = bottom;
OnPropertyChanged(nameof(Highlight));
OnPropertyChanged(nameof(MergeTop));
OnPropertyChanged(nameof(MergeBottom));
if (changed)
{
OnPropertyChanged(nameof(FolderIconSpec));
OnPropertyChanged(nameof(LockIconSpec));
OnPropertyChanged(nameof(HiddenIconSpec));
OnPropertyChanged(nameof(ChevronSpec));
}
}
/// <summary>폴더 아이콘 사양 — 강조 밴드 위에서는 흰색으로 뒤집는다</summary>
public string FolderIconSpec => "folder|14|" + highlight switch
{
LayerHighlight.Blue => "B.OnAccent",
LayerHighlight.Navy => "B.AccentText",
_ => "B.Accent",
};
#endregion
#region Methods
/// <summary>멤버 플래그가 밖에서 바뀌었을 때 아이콘·토글 상태 재계산</summary>
public void NotifyFlagsChanged()
@@ -119,6 +176,8 @@ public sealed class LayerGroupViewModel : ViewModelBase
OnPropertyChanged(nameof(IsLockedFlag));
OnPropertyChanged(nameof(HiddenIcon));
OnPropertyChanged(nameof(LockIcon));
OnPropertyChanged(nameof(HiddenIconSpec));
OnPropertyChanged(nameof(LockIconSpec));
}
/// <summary>이름이 없을 때의 기본 표시</summary>
@@ -0,0 +1,186 @@
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.ViewModels;
/// <summary>
/// 레이어 행의 강조 상태 — [200]SheetMe 의 3상태(OutlineHL)를 그대로 옮긴 것.
/// </summary>
public enum LayerHighlight
{
/// <summary>강조 없음</summary>
None,
/// <summary>선택된 행 — 채워진 강조 밴드</summary>
Blue,
/// <summary>그룹이 통째로 선택됐을 때의 멤버 행 — 폴더가 파랑을 가져가고 멤버는 짙은 서브트리 배경</summary>
Navy,
}
/// <summary>
/// 레이어 목록 행 공통 — 강조 상태와 연속 병합 정보를 노출한다.
///
/// 선택 표시를 <c>ListBoxItem.IsSelected</c> 가 아니라 이 값으로 그리는 이유:
/// 그룹 폴더(파랑)와 그 멤버(네이비)는 <b>색이 다른데도 하나의 이어진 블록</b>이어야 하고,
/// 페이지 행처럼 선택 자체가 불가능하면서 배경은 따로 가지는 행도 있다.
/// IsSelected 로는 이 셋을 한 축으로 표현할 수 없다.
/// </summary>
public interface ILayerRow
{
/// <summary>이 행의 강조 상태</summary>
LayerHighlight Highlight { get; }
/// <summary>바로 위 행도 강조 상태다 — 위쪽 모서리를 각지게 해 블록을 잇는다</summary>
bool MergeTop { get; }
/// <summary>바로 아래 행도 강조 상태다 — 아래쪽 모서리를 각지게 하고 1px 겹친다</summary>
bool MergeBottom { get; }
/// <summary>목록에서 선택 대상인가 — 페이지 행·추가 행은 false</summary>
bool IsSelectableRow { get; }
/// <summary>강조/병합 상태 적용(디자이너가 일괄 계산해 밀어 넣는다)</summary>
void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom);
}
/// <summary>
/// 레이어 목록의 페이지 헤더 행 — [200] PageHeaderRow 대응.
///
/// 페이지를 목록 바깥의 별도 섹션이 아니라 아웃라인의 최상위 노드로 둔다. 부수 효과가 하나 있는데
/// 의도한 것이다: 페이지 행은 항상 <see cref="LayerHighlight.None"/> 이라 연속 강조 블록이
/// 페이지 경계에서 저절로 끊긴다([200] 이 Separator 노드로 하던 일).
/// </summary>
public sealed class LayerPageViewModel : ViewModelBase, ILayerRow
{
#region Member Fields
private readonly DesignerViewModel designer;
private LayerHighlight highlight;
private bool mergeTop;
private bool mergeBottom;
#endregion
#region Properties
/// <summary>대상 페이지</summary>
public PageViewModel Page { get; }
/// <summary>표시 제목 — "페이지 1"</summary>
public string Title => $"페이지 {Page.Index + 1}";
/// <summary>컨트롤 수 표시 — 컨테이너 자식까지 센다</summary>
public string CountText => $"({CountControls(Page.Controls)})";
/// <summary>활성 페이지인가 — 배경 강조와 아이콘 색이 갈린다</summary>
public bool IsCurrent => ReferenceEquals(designer.SelectedPage, Page);
/// <summary>컨트롤이 하나도 없는가 — "(비어 있음)" 둘째 줄</summary>
public bool IsEmpty => Page.Controls.Count == 0;
/// <summary>삭제 버튼을 그릴지 — 마지막 한 장은 지울 수 없다</summary>
public bool CanDelete => designer.Pages.Count > 1;
/// <summary>펼침 상태</summary>
public bool IsExpanded
{
get => designer.IsPageExpanded(Page);
set
{
designer.SetPageExpanded(Page, value);
OnPropertyChanged(nameof(IsExpanded));
OnPropertyChanged(nameof(ChevronIcon));
OnPropertyChanged(nameof(ChevronSpec));
}
}
/// <summary>펼침 셰브론 아이콘명</summary>
public string ChevronIcon => IsExpanded ? "chevron-down" : "chevron-right";
/// <summary>펼침 셰브론 사양 — 페이지 행은 강조되지 않으므로 색이 고정이다</summary>
public string ChevronSpec => $"{ChevronIcon}|12|B.Muted";
/// <summary>페이지 아이콘 사양 — 활성 페이지만 강조색</summary>
public string PageIconSpec => IsCurrent ? "file-text|13|B.Accent" : "file-text|13|B.Muted";
/// <summary>첫 페이지인가 — 위 여백을 줄지 판단</summary>
public bool IsFirst => Page.Index == 0;
#endregion
#region Constructors
public LayerPageViewModel(DesignerViewModel designer, PageViewModel page)
{
this.designer = designer;
Page = page;
}
#endregion
#region ILayerRow
public LayerHighlight Highlight => highlight;
public bool MergeTop => mergeTop;
public bool MergeBottom => mergeBottom;
public bool IsSelectableRow => false;
public void ApplyHighlight(LayerHighlight value, bool top, bool bottom)
{
// 페이지 행은 강조 대상이 아니다 — 무엇을 받든 None 으로 눌러 연속 블록을 끊는다
highlight = LayerHighlight.None;
mergeTop = false;
mergeBottom = false;
OnPropertyChanged(nameof(Highlight));
OnPropertyChanged(nameof(MergeTop));
OnPropertyChanged(nameof(MergeBottom));
}
#endregion
#region Methods
/// <summary>표시 상태 재계산 통지 — 활성 페이지 전환·컨트롤 증감 후</summary>
public void NotifyStateChanged()
{
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(CountText));
OnPropertyChanged(nameof(IsCurrent));
OnPropertyChanged(nameof(IsEmpty));
OnPropertyChanged(nameof(CanDelete));
OnPropertyChanged(nameof(PageIconSpec));
OnPropertyChanged(nameof(IsFirst));
}
/// <summary>컨테이너 자식까지 포함한 컨트롤 수</summary>
private static int CountControls(IEnumerable<ControlViewModel> items)
{
var total = 0;
foreach (var item in items)
{
total++;
if (item is ContainerViewModel container)
{
total += CountControls(container.Children);
}
}
return total;
}
#endregion
}
/// <summary>
/// 목록 맨 아래의 '+ 페이지 추가' 행 — [200] AddPageRow 대응.
/// 페이지 섹션을 없앤 자리에서 페이지 추가 진입점을 대신한다.
/// </summary>
public sealed class LayerAddPageViewModel : ILayerRow
{
#region ILayerRow
public LayerHighlight Highlight => LayerHighlight.None;
public bool MergeTop => false;
public bool MergeBottom => false;
public bool IsSelectableRow => false;
public void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom)
{
// 항상 무강조 — 받을 상태가 없다
}
#endregion
}
@@ -41,6 +41,7 @@ public partial class DesignerCanvasView : UserControl
{
subscribedDesigner.InlineEditRequested -= ShowInlineEditor;
subscribedDesigner.ScrollToRequested -= ScrollToControl;
subscribedDesigner.ScrollToPageRequested -= ScrollToPage;
}
CloseInlineEditor(commit: false);
subscribedDesigner = e.NewValue as DesignerViewModel;
@@ -48,10 +49,29 @@ public partial class DesignerCanvasView : UserControl
{
subscribedDesigner.InlineEditRequested += ShowInlineEditor;
subscribedDesigner.ScrollToRequested += ScrollToControl;
subscribedDesigner.ScrollToPageRequested += ScrollToPage;
subscribedDesigner.ReportViewport(Scroll.ViewportWidth, Scroll.ViewportHeight);
}
}
/// <summary>
/// 페이지 머리가 화면 위쪽에 오도록 스크롤 — 레이어 아웃라인의 페이지 헤더 클릭 경로.
/// 캔버스가 전 페이지를 하나의 월드에 세로로 쌓아 그리므로 페이지 오프셋만 알면 된다.
/// </summary>
private void ScrollToPage(PageViewModel page)
{
if (subscribedDesigner is null)
{
return;
}
Dispatcher.BeginInvoke(new Action(() =>
{
const double CanvasPad = 24; // DesignerCanvasView.xaml 의 Border Padding
const double HeadRoom = 8; // 페이지 윗선이 뷰포트 경계에 딱 붙지 않도록
Scroll.ScrollToVerticalOffset(page.OffsetY * subscribedDesigner.Zoom + CanvasPad - HeadRoom);
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
/// <summary>
/// 컨트롤이 화면 가운데 오도록 스크롤 — 레이어 목록 더블클릭 경로.
///
@@ -0,0 +1,303 @@
<UserControl x:Class="SheetMe.Designer.Views.LayerPanelView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance vm:MainViewModel}"
Background="{DynamicResource B.Panel}">
<!--
레이어 아웃라인 — [200]SheetMe 이식.
페이지를 별도 섹션으로 두지 않고 트리의 최상위 노드로 삼는다. 부수 효과가 하나 있는데
의도한 것이다: 페이지 행은 항상 무강조라 연속 선택 블록이 페이지 경계에서 저절로 끊긴다.
MainView 에서 떼어 낸 이유는 둘이다 — MainView.xaml 이 1,000줄을 넘겼고,
진단 모드(dialog-shots)가 이 패널만 따로 렌더해 라이트/다크·선택 상태를 검사할 수 있어야 한다.
DataContext 는 창에서 상속받는다(MainViewModel).
-->
<UserControl.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:IconSpecConverter x:Key="IconSpec"/>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
</UserControl.Resources>
<DockPanel>
<!-- 검색줄 — 검색칩(가변폭) + 우측 타입 필터 버튼 2개 -->
<DockPanel DockPanel.Dock="Top">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"
VerticalAlignment="Center" Margin="0,8,8,6">
<!-- 타입 필터 — 이름을 몰라도 "체크박스만" 처럼 종류로 좁힐 수 있어야 한다 -->
<Button Width="26" Height="22" Padding="0"
Margin="0,0,2,0" Click="OnLayerFilterClick"
ToolTip="{Binding CurrentDesigner.LayerTypeFilterTip}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Subtle}">
<Setter Property="Content"
Value="{Binding Source=filter, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding CurrentDesigner.HasLayerTypeFilter}" Value="True">
<Setter Property="Content"
Value="{Binding Source=filter, Converter={StaticResource IconName}, ConverterParameter=13|B.Accent}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- 초기화는 숨기지 않고 흐리게 둔다 — 버튼이 사라지면 옆 버튼 위치가 흔들린다 -->
<Button Style="{StaticResource Subtle}" Width="26" Height="22" Padding="0"
Click="OnLayerFilterClearClick" ToolTip="타입 필터 해제"
IsEnabled="{Binding CurrentDesigner.HasLayerTypeFilter}"
Content="{Binding Source=filter-x, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
</StackPanel>
<!--
레이어 검색칩 — 컨트롤이 수백 개인 서식에서 이름으로 좁혀 찾는다.
내부 TextBox 에 MinHeight=0 필수: 전역 TextBox MinHeight 30 때문에 32px 칩에서 글자가 잘린다.
BorderBrush 도 필수: 라이트에서 B.Input 과 B.Panel 이 둘 다 흰색이라
테두리가 없으면 칩 경계 대비가 정확히 1.00:1 — 입력란이 거기 있다는 것 자체가 안 보인다.
-->
<Border Height="32" CornerRadius="7" Margin="8,8,0,6"
Background="{DynamicResource B.Input}"
BorderThickness="1" BorderBrush="{DynamicResource B.InputBorder}">
<DockPanel>
<ContentControl DockPanel.Dock="Left" Margin="9,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding Source=search, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
<Grid>
<TextBox x:Name="LayerSearchBox" MinHeight="0" Padding="0,0,6,0" BorderThickness="0"
Background="Transparent" VerticalContentAlignment="Center"
Text="{Binding CurrentDesigner.LayerFilter, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="레이어 검색…" FontSize="12" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" IsHitTestVisible="False" Margin="1,0,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Text, ElementName=LayerSearchBox}" Value="">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</DockPanel>
</Border>
</DockPanel>
<!--
아웃라인 — 페이지 헤더 / 그룹 폴더 / 컨트롤 / '페이지 추가' 네 종류가 한 시퀀스로 섞인다.
ItemTemplate 하나로는 안 되고(암시 DataType 매칭은 LabelViewModel 같은 구체 형에만 걸린다)
선택기로 가른다. 계층은 LayerIndent 여백으로만 표현한다 — TreeView 로 바꾸면
1,067행 서식에서 가상화를 잃는다.
행 배경·모서리는 LayerRowItem 스타일이 Highlight 3상태로 그린다(IsSelected 아님).
그래서 DataTemplate 루트에는 세로 Margin 을 주면 안 된다 — 1px 만 있어도
선택 블록이 이어지지 않는다.
-->
<Grid Margin="6,0,6,6">
<ListBox x:Name="LayerListBox" ItemsSource="{Binding CurrentDesigner.LayerRows}"
bh:LayerListBehavior.Designer="{Binding CurrentDesigner}"
bh:LayerDragBehavior.Designer="{Binding CurrentDesigner}"
ItemContainerStyle="{StaticResource LayerRowItem}"
SelectionMode="Extended" Padding="0,6,0,0"
MouseDoubleClick="OnLayerDoubleClick"
PreviewMouseLeftButtonDown="OnLayerRowPressed"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplateSelector>
<ctl:LayerRowTemplateSelector>
<!-- 페이지 헤더 — 셰브론 / 문서 아이콘 / "페이지 N" / (컨트롤수) / 복제·삭제 -->
<ctl:LayerRowTemplateSelector.PageTemplate>
<DataTemplate>
<DockPanel Margin="4,4,4,4">
<DockPanel.Style>
<Style TargetType="DockPanel">
<Style.Triggers>
<DataTrigger Binding="{Binding IsFirst}" Value="False">
<Setter Property="Margin" Value="4,10,4,4"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DockPanel.Style>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource Subtle}" Width="24" Height="22" Padding="0"
Margin="1,0,0,0" Click="OnPageDuplicateClick" ToolTip="페이지 복제"
Content="{Binding Source=copy, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
<Button Style="{StaticResource Subtle}" Width="24" Height="22" Padding="0"
Margin="1,0,0,0" Click="OnPageDeleteClick" ToolTip="페이지 삭제"
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}"
Content="{Binding Source=trash-2, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
</StackPanel>
<StackPanel>
<StackPanel Orientation="Horizontal">
<ToggleButton Style="{StaticResource MiniChevron}" Width="18" Height="20"
IsChecked="{Binding IsExpanded}" ToolTip="페이지 접기/펼치기"
Content="{Binding ChevronSpec, Converter={StaticResource IconSpec}}"/>
<ContentControl Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding PageIconSpec, Converter={StaticResource IconSpec}}"/>
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding CountText}" FontSize="11" Margin="6,0,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource B.Muted}"/>
</StackPanel>
<TextBlock Text="(비어 있음)" FontSize="11" Margin="20,1,0,0"
Foreground="{DynamicResource B.Muted}"
Visibility="{Binding IsEmpty, Converter={StaticResource BoolToVisibility}}"/>
</StackPanel>
</DockPanel>
</DataTemplate>
</ctl:LayerRowTemplateSelector.PageTemplate>
<!-- 그룹 폴더 — 셰브론 / 폴더 / 이름(더블클릭 편집) / (n) / 일괄 눈·자물쇠 -->
<ctl:LayerRowTemplateSelector.GroupTemplate>
<DataTemplate>
<DockPanel Margin="6,4,6,4">
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsLockedFlag}" ToolTip="그룹 전체 잠금"
Content="{Binding LockIconSpec, Converter={StaticResource IconSpec}}"/>
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsHiddenFlag}" ToolTip="그룹 전체 숨김"
Content="{Binding HiddenIconSpec, Converter={StaticResource IconSpec}}"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding MemberCountText}" FontSize="11"
VerticalAlignment="Center" Margin="4,0,4,0">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Highlight}" Value="Blue">
<Setter Property="Foreground" Value="{DynamicResource B.OnSelMuted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Border DockPanel.Dock="Left" Width="12"/>
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="18" Height="20"
IsChecked="{Binding IsExpanded}" ToolTip="접기/펼치기"
Content="{Binding ChevronSpec, Converter={StaticResource IconSpec}}"/>
<ContentControl DockPanel.Dock="Left" Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding FolderIconSpec, Converter={StaticResource IconSpec}}"/>
<Grid VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
ToolTip="더블클릭하면 그룹 이름을 바꿉니다">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox MinHeight="0" Padding="2,0" FontSize="12"
IsVisibleChanged="OnGroupNameBoxVisibleChanged"
Text="{Binding Name, UpdateSourceTrigger=LostFocus}"
KeyDown="OnGroupNameKeyDown" LostFocus="OnGroupNameLostFocus"
Visibility="{Binding IsEditing, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</DockPanel>
</DataTemplate>
</ctl:LayerRowTemplateSelector.GroupTemplate>
<!-- 컨트롤 행 -->
<ctl:LayerRowTemplateSelector.ControlTemplate>
<DataTemplate>
<DockPanel Margin="8,4,6,4">
<!-- 눈·자물쇠는 아이콘이 상태에 따라 바뀐다 — 사각 체크로는 잠김/숨김이 안 읽힌다 -->
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsLockedFlag}" ToolTip="잠금"
Content="{Binding LockIconSpec, Converter={StaticResource IconSpec}}"/>
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김"
Content="{Binding HiddenIconSpec, Converter={StaticResource IconSpec}}"/>
<!-- 계층 들여쓰기: 페이지 직속 12, 그룹 멤버 32, 컨테이너 자식 +16 -->
<Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/>
<!-- 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다 -->
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="16"
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기"
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}"
Content="{Binding LayerChevronSpec, Converter={StaticResource IconSpec}}"/>
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding LayerIconSpec, Converter={StaticResource IconSpec}}"/>
<!-- 데이터 배선 배지 — 값이 조회/데이터소스에서 오는 컨트롤 -->
<ContentControl DockPanel.Dock="Right" Margin="0,0,3,0" VerticalAlignment="Center" IsTabStop="False"
ToolTip="데이터소스·조회와 연결된 컨트롤"
Visibility="{Binding HasDataBinding, Converter={StaticResource BoolToVisibility}}"
Content="{Binding Source=link, Converter={StaticResource IconName}, ConverterParameter=12|B.Muted}"/>
<!--
문구와 이름을 두 줄로. 컨트롤 이름(Id)은 외부 참조 키라
(액션 대상·입력 파라미터가 이름으로 서로를 가리키고 CalcBox 수식도 이름으로 합산한다)
배선하려면 목록에서 보여야 하는데, 248px 폭에 나란히 두면 양쪽 다 잘려
'txtKinPhone…' 처럼 구분이 안 되는 꼬리가 남는다. 한 줄 더 쓰는 편이 낫다.
표시 문구가 없어 이름이 곧 문구인 행에서는 둘째 줄이 뜨지 않는다.
-->
<StackPanel VerticalAlignment="Center">
<!-- 숨김 흐리기는 이름 줄에만 — 행 전체를 깎으면 눈·자물쇠 아이콘 대비까지 무너진다 -->
<TextBlock Text="{Binding LayerName}"
TextTrimming="CharacterEllipsis" ToolTip="{Binding LayerName}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsHiddenFlag}" Value="True">
<Setter Property="Opacity" Value="0.55"/>
</DataTrigger>
<DataTrigger Binding="{Binding Highlight}" Value="Blue">
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding LayerId}" FontSize="10.5" Margin="0,1,0,0"
TextTrimming="CharacterEllipsis"
ToolTip="컨트롤 이름 — 다른 속성이 이 이름으로 참조합니다"
Visibility="{Binding HasLayerId, Converter={StaticResource BoolToVisibility}}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Style.Triggers>
<!-- 선택 밴드 위에서 B.Muted 는 1.03:1 — 둘째 줄이 통째로 사라진다 -->
<DataTrigger Binding="{Binding Highlight}" Value="Blue">
<Setter Property="Foreground" Value="{DynamicResource B.OnSelMuted}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Highlight}" Value="Navy">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DockPanel>
</DataTemplate>
</ctl:LayerRowTemplateSelector.ControlTemplate>
<!-- 맨 아래 '페이지 추가' — 페이지 섹션을 없앤 자리의 진입점 -->
<ctl:LayerRowTemplateSelector.AddPageTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="5,6,4,6"
Background="Transparent" ToolTip="맨 뒤에 빈 페이지를 추가합니다">
<ContentControl Margin="2,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding Source=plus, Converter={StaticResource IconName}, ConverterParameter=13|B.Muted}"/>
<TextBlock Text="페이지 추가" FontSize="12" VerticalAlignment="Center"
Foreground="{DynamicResource B.Muted}"/>
</StackPanel>
</DataTemplate>
</ctl:LayerRowTemplateSelector.AddPageTemplate>
</ctl:LayerRowTemplateSelector>
</ListBox.ItemTemplateSelector>
</ListBox>
<!-- 빈 목록 안내 — 검색·필터가 다 걸러냈는지, 애초에 컨트롤이 없는지 구분해 준다 -->
<TextBlock Text="{Binding CurrentDesigner.LayerEmptyText}" FontSize="12"
Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" TextAlignment="Center"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="12,0"
IsHitTestVisible="False"
Visibility="{Binding CurrentDesigner.IsLayerListEmpty, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</DockPanel>
</UserControl>
@@ -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
}
+8 -178
View File
@@ -46,7 +46,9 @@
<ctl:TypeToIconConverter x:Key="TypeToIcon"/>
<ctl:TypeToCategoryConverter x:Key="TypeToCategory"/>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:IconSpecConverter x:Key="IconSpec"/>
<ctl:InverseBoolConverter x:Key="InverseBool"/>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<vm:SheetClassToGroupConverter x:Key="SheetClassToGroup"/>
@@ -472,189 +474,17 @@
</Grid>
</DockPanel>
</TabItem>
<!-- 레이어 (+ 페이지) -->
<!--
레이어 — [200]SheetMe 아웃라인.
페이지를 별도 섹션으로 두지 않고 트리의 최상위 노드로 삼는다. 부수 효과가 하나 있는데
의도한 것이다: 페이지 행은 항상 무강조라 연속 선택 블록이 페이지 경계에서 저절로 끊긴다.
-->
<TabItem>
<TabItem.Header><TextBlock Text="레이어" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
<DockPanel>
<!-- 페이지 섹션 -->
<DockPanel DockPanel.Dock="Top" Margin="0,4,0,0">
<TextBlock DockPanel.Dock="Top" Text="페이지" FontWeight="Bold" FontSize="11"
Margin="10,4,10,4" Foreground="{DynamicResource B.Muted}"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="10,2,10,6">
<Button Content="추가" Command="{Binding CurrentDesigner.AddPageCommand}"/>
<Button Content="삭제" Margin="6,0,0,0" Command="{Binding CurrentDesigner.RemovePageCommand}"/>
</StackPanel>
<ListBox ItemsSource="{Binding CurrentDesigner.Pages}"
SelectedItem="{Binding CurrentDesigner.SelectedPage}"
MaxHeight="110" Margin="4,0">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="2,1">
<TextBlock Text="{Binding Index, StringFormat=페이지 {0}}" FontSize="12"/>
<TextBlock Text="{Binding SizeText, StringFormat= ({0})}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<Border DockPanel.Dock="Top" Height="1" Background="{DynamicResource B.Line}" Margin="8,2"/>
<!-- 레이어 검색칩 — 컨트롤이 수백 개인 서식에서 이름으로 좁혀 찾는다.
내부 TextBox 에 MinHeight=0 필수: 전역 TextBox MinHeight 30 때문에 32px 칩에서 글자가 잘린다. -->
<Border DockPanel.Dock="Top" Height="32" CornerRadius="7" Margin="8,2,8,4"
Background="{DynamicResource B.Input}">
<DockPanel>
<ContentControl DockPanel.Dock="Left" Margin="9,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding Source=search, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<!-- 타입 필터 — 이름을 몰라도 "체크박스만" 처럼 종류로 좁힐 수 있어야 한다 -->
<Button DockPanel.Dock="Right" Style="{StaticResource Subtle}" Width="26" Height="22" Padding="0"
Margin="0,0,3,0" Click="OnLayerFilterClick"
ToolTip="{Binding CurrentDesigner.LayerTypeFilterTip}"
Content="{Binding Source=filter, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<Button DockPanel.Dock="Right" Style="{StaticResource Subtle}" Width="26" Height="22" Padding="0"
Margin="0,0,1,0" Click="OnLayerFilterClearClick" ToolTip="타입 필터 해제"
Visibility="{Binding CurrentDesigner.HasLayerTypeFilter, Converter={StaticResource BoolToVisibility}}"
Content="{Binding Source=filter-x, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<Grid>
<TextBox x:Name="LayerSearchBox" MinHeight="0" Padding="0" BorderThickness="0"
Background="Transparent" VerticalContentAlignment="Center"
Text="{Binding CurrentDesigner.LayerFilter, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="레이어 검색…" FontSize="12" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" IsHitTestVisible="False" Margin="1,0,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Text, ElementName=LayerSearchBox}" Value="">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</DockPanel>
</Border>
<!--
레이어 목록 (활성 페이지, 그리기 순서).
그룹 폴더 행과 컨트롤 행이 한 시퀀스로 섞이는 평탄화 목록이라
ItemTemplate 하나로는 안 되고(암시 DataType 매칭은 LabelViewModel 같은
구체 형에만 걸린다) 선택기로 가른다. 계층은 LayerIndent 여백으로만 표현.
-->
<Grid Margin="4,0,4,6">
<ListBox x:Name="LayerListBox" ItemsSource="{Binding CurrentDesigner.LayerRows}"
bh:LayerListBehavior.Designer="{Binding CurrentDesigner}"
SelectionMode="Extended"
MouseDoubleClick="OnLayerDoubleClick"
ToolTip="더블클릭 — 컨트롤은 캔버스 이동, 그룹은 이름 바꾸기"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplateSelector>
<ctl:LayerRowTemplateSelector>
<!-- 그룹 폴더 행 — 셰브론 / 폴더 / 이름(더블클릭 편집) / (n) / 일괄 눈·자물쇠 -->
<ctl:LayerRowTemplateSelector.GroupTemplate>
<DataTemplate>
<DockPanel Margin="0,1">
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsLockedFlag}" ToolTip="그룹 전체 잠금"
Content="{Binding LockIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsHiddenFlag}" ToolTip="그룹 전체 숨김"
Content="{Binding HiddenIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="18"
IsChecked="{Binding IsExpanded}" ToolTip="접기/펼치기"
Content="{Binding ChevronIcon, Converter={StaticResource IconName}, ConverterParameter=12}"/>
<ContentControl DockPanel.Dock="Left" Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding Source=folder, Converter={StaticResource IconName}, ConverterParameter=14|B.Accent}"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding MemberCountText}" FontSize="11"
VerticalAlignment="Center" Margin="4,0,4,0"
Foreground="{DynamicResource B.Muted}"/>
<Grid VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
ToolTip="더블클릭하면 그룹 이름을 바꿉니다">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox MinHeight="0" Padding="2,0" FontSize="12"
IsVisibleChanged="OnGroupNameBoxVisibleChanged"
Text="{Binding Name, UpdateSourceTrigger=LostFocus}"
KeyDown="OnGroupNameKeyDown" LostFocus="OnGroupNameLostFocus"
Visibility="{Binding IsEditing, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</DockPanel>
</DataTemplate>
</ctl:LayerRowTemplateSelector.GroupTemplate>
<ctl:LayerRowTemplateSelector.ControlTemplate>
<DataTemplate>
<DockPanel Margin="0,1">
<!-- 눈·자물쇠는 아이콘이 상태에 따라 바뀐다 — 사각 체크로는 잠김/숨김이 안 읽힌다 -->
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsLockedFlag}" ToolTip="잠금"
Content="{Binding LockIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김"
Content="{Binding HiddenIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<!-- 계층 들여쓰기: 그룹 멤버 20, 컨테이너 자식 +16 -->
<Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/>
<!-- 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다 -->
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="16"
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기"
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}"
Content="{Binding LayerChevronIcon, Converter={StaticResource IconName}, ConverterParameter=12}"/>
<ContentControl DockPanel.Dock="Left" Margin="2,0,6,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding Type, Converter={StaticResource TypeToIcon}, ConverterParameter=14|B.Accent}"/>
<!--
문구와 이름을 두 줄로. 컨트롤 이름(Id)은 외부 참조 키라
(액션 대상·입력 파라미터가 이름으로 서로를 가리키고 CalcBox 수식도 이름으로 합산한다)
배선하려면 목록에서 보여야 하는데, 248px 폭에 나란히 두면 양쪽 다 잘려
'txtKinPhone…' 처럼 구분이 안 되는 꼬리가 남는다. 한 줄 더 쓰는 편이 낫다.
표시 문구가 없어 이름이 곧 문구인 행에서는 둘째 줄이 뜨지 않는다.
-->
<StackPanel VerticalAlignment="Center">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Style.Triggers>
<DataTrigger Binding="{Binding IsHiddenFlag}" Value="True">
<Setter Property="Opacity" Value="0.55"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="{Binding LayerName}" FontSize="12"
TextTrimming="CharacterEllipsis" ToolTip="{Binding LayerName}"/>
<TextBlock Text="{Binding LayerId}" FontSize="10.5" Margin="0,1,0,0"
Foreground="{DynamicResource B.Muted}"
TextTrimming="CharacterEllipsis"
ToolTip="컨트롤 이름 — 다른 속성이 이 이름으로 참조합니다"
Visibility="{Binding HasLayerId, Converter={StaticResource BoolToVisibility}}"/>
</StackPanel>
</DockPanel>
</DataTemplate>
</ctl:LayerRowTemplateSelector.ControlTemplate>
</ctl:LayerRowTemplateSelector>
</ListBox.ItemTemplateSelector>
</ListBox>
<!-- 빈 목록 안내 — 검색·필터가 다 걸러냈는지, 애초에 컨트롤이 없는지 구분해 준다 -->
<TextBlock Text="{Binding CurrentDesigner.LayerEmptyText}" FontSize="12"
Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" TextAlignment="Center"
HorizontalAlignment="Center" VerticalAlignment="Top" Margin="12,18,12,0"
IsHitTestVisible="False"
Visibility="{Binding CurrentDesigner.IsLayerListEmpty, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
<v:LayerPanelView/>
</DockPanel>
</TabItem>
<!-- 도구 상자 ([200] 타일 카드 룩 — 드래그/더블클릭 배치) -->
<TabItem>
<TabItem.Header><TextBlock Text="도구 상자" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
-100
View File
@@ -38,106 +38,6 @@ public partial class MainView : Window
}
}
/// <summary>
/// 레이어 더블클릭 → 컨트롤은 캔버스 이동, 그룹 폴더는 이름 편집 시작.
/// 컨트롤이 수백 개인 서식에서는 목록에서 이름을 찾아도 종이 어디에 있는지 알 수 없었다.
/// (ListBox 더블클릭은 InputBinding 을 지원하지 않아 코드비하인드로 위임한다 — 서식 목록과 같은 이유)
/// </summary>
private void OnLayerDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is not MainViewModel { CurrentDesigner: { } designer })
{
return;
}
switch (LayerListBox.SelectedItem)
{
case ViewModels.Controls.ControlViewModel target:
designer.RevealControl(target);
break;
case LayerGroupViewModel group:
group.IsEditing = true;
break;
}
}
/// <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;
}
}
/// <summary>레이어 타입 필터 — 활성 페이지에 실제로 있는 타입만 후보로 띄운다</summary>
private void OnLayerFilterClick(object sender, RoutedEventArgs e)
{
if (DataContext is not MainViewModel { CurrentDesigner: { } designer })
{
return;
}
var dialog = new LayerTypeFilterDialog(designer.LayerTypeCandidates(), designer.LayerTypeFilter)
{
Owner = this,
};
if (dialog.ShowDialog() == true)
{
designer.SetLayerTypeFilter(dialog.SelectedTypes);
}
}
/// <summary>타입 필터 해제</summary>
private void OnLayerFilterClearClick(object sender, RoutedEventArgs e)
=> (DataContext as MainViewModel)?.CurrentDesigner?.SetLayerTypeFilter(Array.Empty<string>());
private void OnThemeToggleClick(object sender, RoutedEventArgs e)
{
ThemeManager.Toggle();