레이어 패널에 [200]SheetMe 그룹 기능 이식 — 폴더 행·계층·타입 필터·다중선택

[200] 의 그룹도 컨테이너가 아니라 GroupId 태그 + 이름 사전이었다. SheetMe 가 이미
같은 구조(ControlElement.GroupId, JsonIgnore)를 갖고 있어 저장 포맷은 그대로 두고
표시·조작 계층만 얹는다 — 왕복 XML 에는 아무 흔적도 남지 않는다.

레이어 목록을 평탄화 결과(LayerRows)로 바꿨다. 그룹 폴더 행과 컨트롤 행이 한
시퀀스로 섞이고, 계층은 들여쓰기 여백으로만 표현한다(TreeView 를 쓰면 가상화와
기존 행 템플릿을 다 버려야 한다 — 인스펙터 섹션이 쓰는 방식과 같게 맞췄다).

새로 되는 것
- 그룹 폴더 행: 접기/펼치기, 멤버 수, 이름 인라인 편집(더블클릭), 그룹 전체 눈·자물쇠
- 그룹 이름 — FormDocument.GroupNames(편집 전용). 비우면 기본 그룹 으로 복귀
- 컨테이너(패널/그룹박스) 자식이 레이어에 보인다. 지금까지는 아예 접근할 수 없었다
- 목록 다중선택(Shift/Ctrl) — 캔버스 선택과 양방향 동기
- 타입 필터: 그 페이지에 실제로 있는 타입만 후보로 띄운다
- 검색 0건·빈 페이지 안내 문구

함께 고친 결함
- 붙여넣기가 원본 그룹에 끼어들었다(Clone 이 GroupId 를 그대로 복사) → 복제본끼리 새 그룹
- 그룹 해제가 선택된 멤버만 풀어 1개짜리 반쪽 그룹이 남았다 → 그룹 전 멤버를 푼다
- 멤버가 1개 이하로 줄어든 그룹이 계속 남았다 → CleanupGroups 자가치유
- 셰브론이 늘 눌린 것처럼 보였다(IsChecked 채움) → MiniChevron 스타일 분리

--edit-smoke 에 레이어 검사 19건 추가(그룹 위생·평탄화·필터·XML 무영향).
회귀: 테스트 124/124, edit-smoke 실패 0, db-smoke 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-12 15:43:37 +09:00
co-authored by Claude Opus 5
parent d09bd90c8e
commit a30421f9b5
14 changed files with 1154 additions and 89 deletions
+11
View File
@@ -18,6 +18,16 @@ public sealed class FormDocument
/// <summary>부가 메타(출처/감사/읽기 경고)</summary> /// <summary>부가 메타(출처/감사/읽기 경고)</summary>
public FormMeta Meta { get; set; } = new(); public FormMeta Meta { get; set; } = new();
/// <summary>
/// 디자이너 그룹 표시 이름(GroupId → 이름) — 직렬화 안 함(편집 전용 상태,
/// <see cref="ControlElement.GroupId"/> 와 수명이 같다).
///
/// 이름이 없는 그룹은 목록에 "그룹"으로만 뜨는데, 한 서식에 그룹이 여럿이면 구분이 안 된다.
/// 값을 지우면(빈 문자열) 키를 제거해 기본 표시로 돌아간다.
/// </summary>
[System.Text.Json.Serialization.JsonIgnore]
public Dictionary<string, string> GroupNames { get; set; } = new(StringComparer.Ordinal);
#endregion #endregion
#region Methods #region Methods
@@ -29,6 +39,7 @@ public sealed class FormDocument
FormId = FormId, FormId = FormId,
Title = Title, Title = Title,
Meta = Meta.Clone(), Meta = Meta.Clone(),
GroupNames = new Dictionary<string, string>(GroupNames, StringComparer.Ordinal),
}; };
foreach (var page in Pages) foreach (var page in Pages)
{ {
@@ -0,0 +1,159 @@
using System.Windows;
using System.Windows.Controls;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Behaviors;
/// <summary>
/// 레이어 목록 ↔ 캔버스 선택 양방향 동기.
///
/// 목록을 <c>SelectedItem</c> 한 개로 바인딩하면 다중선택이 불가능하고(그룹 폴더 행을 고르면
/// 형이 맞지 않아 바인딩이 조용히 실패한다), 반대로 선택 서비스만 진실로 두면 목록이 캔버스 선택을
/// 따라오지 못한다. 그래서 <c>SelectionMode=Extended</c> 로 두고 여기서 양쪽을 잇는다.
///
/// 진실은 언제나 <see cref="Services.SelectionService"/> 다 — 목록은 그 투영이며,
/// 잠긴 컨트롤처럼 선택에서 걸러진 항목은 목록에서도 도로 풀린다.
/// </summary>
public static class LayerListBehavior
{
#region Attached Properties
/// <summary>동기 대상 디자이너 — 문서 탭이 바뀌면 구독을 갈아탄다</summary>
public static readonly DependencyProperty DesignerProperty = DependencyProperty.RegisterAttached(
"Designer", typeof(DesignerViewModel), typeof(LayerListBehavior),
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 static readonly HashSet<ListBox> syncing = new();
private static readonly Dictionary<ListBox, (DesignerViewModel Designer, Action OnSelection, Action OnRebuilt)> hooks = new();
#endregion
#region Methods
private static void OnDesignerChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
if (sender is not ListBox list)
{
return;
}
if (hooks.Remove(list, out var previous))
{
previous.Designer.Selection.SetChanged -= previous.OnSelection;
previous.Designer.LayerRowsRebuilt -= previous.OnRebuilt;
}
list.SelectionChanged -= OnListSelectionChanged;
list.SelectionChanged += OnListSelectionChanged;
if (e.NewValue is not DesignerViewModel designer)
{
return;
}
void OnSelection() => SyncFromSelection(list, designer);
// 목록을 다시 만들면 ListBox 선택이 통째로 비워진다 — 그 직후 강조를 되살린다
void OnRebuilt() => SyncFromSelection(list, designer);
hooks[list] = (designer, OnSelection, OnRebuilt);
designer.Selection.SetChanged += OnSelection;
designer.LayerRowsRebuilt += OnRebuilt;
SyncFromSelection(list, designer);
}
/// <summary>목록 → 선택 서비스. 그룹 폴더 행은 멤버 전체로 펼쳐서 넘긴다.</summary>
private static void OnListSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListBox list || syncing.Contains(list) || GetDesigner(list) is not { } designer)
{
return;
}
// 목록 재구성 중의 선택 해제는 사용자의 조작이 아니다 — 그대로 흘리면 캔버스 선택이 풀린다
if (designer.IsRebuildingLayers)
{
return;
}
var targets = new List<ControlViewModel>();
foreach (var row in list.SelectedItems)
{
foreach (var control in DesignerViewModel.ControlsOfLayerRow(row))
{
if (!targets.Contains(control))
{
targets.Add(control);
}
}
}
// 마지막에 고른 것을 Primary 로 — 정렬·크기맞춤의 기준 컨트롤이 된다
var primary = e.AddedItems.Count > 0
? DesignerViewModel.ControlsOfLayerRow(e.AddedItems[^1]).LastOrDefault()
: null;
syncing.Add(list);
try
{
designer.Selection.Set(targets, primary ?? targets.LastOrDefault());
}
finally
{
syncing.Remove(list);
}
// 결과가 목록의 현재 강조와 다를 때만 되맞춘다 — 그룹 폴더를 골라 멤버까지 켜야 하거나,
// 서비스가 잠긴 항목을 걸러낸 경우다.
//
// 항상 되맞추면 SelectedItems 를 갈아 끼우면서 ListBox 의 <b>범위 선택 기준점(anchor)</b>이
// 매번 초기화돼 Shift+클릭·Shift+↓ 가 연속 범위가 아니라 띄엄띄엄 골라진다.
var expected = ExpectedRows(list, designer);
if (expected.Count != list.SelectedItems.Count
|| expected.Any(row => !list.SelectedItems.Contains(row)))
{
SyncFromSelection(list, designer);
}
}
/// <summary>선택 서비스 → 목록. 그룹 폴더는 멤버가 전부 선택됐을 때만 함께 강조한다.</summary>
private static void SyncFromSelection(ListBox list, DesignerViewModel designer)
{
if (syncing.Contains(list))
{
return;
}
syncing.Add(list);
try
{
list.SelectedItems.Clear();
foreach (var row in ExpectedRows(list, designer))
{
list.SelectedItems.Add(row);
}
}
finally
{
syncing.Remove(list);
}
}
/// <summary>현재 선택으로부터 강조돼야 할 행들</summary>
private static List<object> ExpectedRows(ListBox list, DesignerViewModel designer)
{
var selected = designer.Selection.Items;
var rows = new List<object>();
foreach (var row in list.Items)
{
var controls = DesignerViewModel.ControlsOfLayerRow(row).ToList();
if (controls.Count > 0 && controls.All(selected.Contains))
{
rows.Add(row);
}
}
return rows;
}
#endregion
}
@@ -0,0 +1,24 @@
using System.Windows;
using System.Windows.Controls;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 레이어 목록 행 템플릿 선택 — 그룹 폴더 행과 컨트롤 행을 가른다.
///
/// <c>DataTemplate DataType</c> 암시 매칭을 쓸 수 없다: WPF 는 <b>정확한 런타임 형</b>으로만 찾는데
/// 컨트롤 행의 실제 형은 <c>LabelViewModel</c>·<c>TextBoxViewModel</c> 처럼 제각각이고
/// 공통 기반인 <c>ControlViewModel</c> 로는 매칭되지 않는다.
/// </summary>
public sealed class LayerRowTemplateSelector : DataTemplateSelector
{
/// <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;
}
@@ -126,6 +126,8 @@ public static class DialogShots
("10-inspector-textbox", () => HostInspector(designer, "TextBox1")), ("10-inspector-textbox", () => HostInspector(designer, "TextBox1")),
("11-inspector-data", () => HostInspector(designer, "TextBox1", InspectorTab.Data)), ("11-inspector-data", () => HostInspector(designer, "TextBox1", InspectorTab.Data)),
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)), ("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
designer.LayerTypeCandidates(), new[] { "Label" })),
}; };
} }
@@ -261,6 +261,118 @@ public static class EditSmoke
Check("폰트 일괄 적용: Undo 1스텝 원복", Check("폰트 일괄 적용: Undo 1스텝 원복",
Find("Label").Model.Props.GetText("Font") != "돋움, 12pt, style=Bold"); Find("Label").Model.Props.GetText("Font") != "돋움, 12pt, style=Bold");
// 20-2-1) 레이어 목록 — 그룹 폴더/계층 평탄화/검색·타입 필터/그룹 위생.
// 뒤 단계들이 designer 의 컨트롤 개수를 세므로 별도 문서에서 검사한다.
{
var lay = new DesignerViewModel(business.CreateNew());
lay.AddControlAt("TextBox", new Point(100, 100));
lay.AddControlAt("Label", new Point(100, 200));
lay.AddControlAt("CheckBox", new Point(100, 300));
ControlViewModel Pick(string type) => lay.Pages[0].Controls.First(c => c.Type == type);
lay.Selection.Set(new[] { Pick("TextBox"), Pick("Label") });
lay.GroupSelection();
var groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().FirstOrDefault();
Check("레이어: 그룹 폴더 행 1개", lay.LayerRows.OfType<LayerGroupViewModel>().Count() == 1);
Check("레이어: 폴더 멤버 2 + 기본 이름",
groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"),
$"실제 {groupRow?.Members.Count} / {groupRow?.Name}");
// 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스)
var folderIndex = lay.LayerRows.IndexOf(groupRow!);
Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐",
folderIndex >= 0
&& lay.LayerRows.Skip(folderIndex + 1).Take(2)
.OfType<ControlViewModel>().Count(c => c.LayerIndent == 20) == 2);
// 접으면 멤버 행이 빠지고, 펴면 되돌아온다
var expandedCount = lay.LayerRows.Count;
groupRow!.IsExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 접으면 멤버 행 제거", lay.LayerRows.Count == expandedCount - 2,
$"실제 {lay.LayerRows.Count} 기대 {expandedCount - 2}");
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
groupRow.IsExpanded = true;
lay.RebuildLayerRows();
Check("레이어: 펴면 멤버 행 복귀", lay.LayerRows.Count == expandedCount);
// 그룹 일괄 숨김이 전 멤버에 퍼진다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
groupRow.IsHiddenFlag = true;
Check("레이어: 그룹 숨김 → 전 멤버", groupRow.Members.All(m => m.IsHiddenFlag));
groupRow.IsHiddenFlag = false;
Check("레이어: 그룹 표시 복귀", groupRow.Members.All(m => !m.IsHiddenFlag));
// 그룹 이름은 문서가 소유 — 비우면 기본값으로 되돌아간다
groupRow.Name = "머리글 묶음";
Check("레이어: 그룹 개명", lay.GroupNameOf(groupRow.GroupId) == "머리글 묶음");
groupRow.Name = string.Empty;
Check("레이어: 이름 비우면 기본값", lay.GroupNameOf(groupRow.GroupId) == "그룹");
// 컨테이너 자식이 계층으로 보이고, 접으면 목록에서 빠진다
lay.AddControlAt("Panel", new Point(300, 600));
var layerPanel = lay.Pages[0].Controls.OfType<PanelViewModel>().First();
lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20));
lay.RebuildLayerRows();
Check("레이어: 컨테이너 자식이 목록에 나옴",
lay.LayerRows.OfType<ControlViewModel>().Any(c => ReferenceEquals(c.Parent, layerPanel)));
Check("레이어: 자식 들여쓰기 = 부모+16",
lay.LayerRows.OfType<ControlViewModel>()
.Where(c => ReferenceEquals(c.Parent, layerPanel))
.All(c => c.LayerIndent == layerPanel.LayerIndent + 16));
var withChild = lay.LayerRows.Count;
layerPanel.IsLayerExpanded = false;
lay.RebuildLayerRows();
Check("레이어: 컨테이너 접으면 자식 제거", lay.LayerRows.Count == withChild - 1);
layerPanel.IsLayerExpanded = true;
lay.RebuildLayerRows();
// 검색은 자손이 걸리면 조상도 남긴다 — 부모가 빠지면 자식이 통째로 사라진다
var childId = layerPanel.Children[0].Id;
lay.LayerFilter = childId;
Check("레이어: 검색 시 조상 보존",
lay.LayerRows.Contains(layerPanel)
&& lay.LayerRows.OfType<ControlViewModel>().Any(c => c.Id == childId));
lay.LayerFilter = "존재하지않는이름____";
Check("레이어: 0건이면 빈 목록 안내", lay.IsLayerListEmpty && lay.LayerEmptyText.Contains("검색"));
lay.LayerFilter = string.Empty;
// 타입 필터
lay.SetLayerTypeFilter(new[] { "CheckBox" });
Check("레이어: 타입 필터 적용",
lay.HasLayerTypeFilter
&& lay.LayerRows.OfType<ControlViewModel>().Any()
&& lay.LayerRows.OfType<ControlViewModel>().All(c => c.Type == "CheckBox"));
lay.SetLayerTypeFilter(Array.Empty<string>());
Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter);
// 붙여넣은 복제본은 원본 그룹에 끼지 않는다(GroupId 재매핑)
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
var sourceGroupId = groupRow.Members[0].Model.GroupId;
lay.Selection.SetSingle(groupRow.Members[0]);
lay.CopySelection();
lay.Paste();
Check("레이어: 붙여넣기가 원본 그룹에 끼지 않음",
lay.Selection.Items[0].Model.GroupId != sourceGroupId,
$"원본 {sourceGroupId} 복제 {lay.Selection.Items[0].Model.GroupId}");
// 멤버가 1개로 줄면 그룹은 자동 해제되고 이름도 정리된다
groupRow = lay.LayerRows.OfType<LayerGroupViewModel>().First();
lay.Selection.SetSingle(groupRow.Members[1]);
lay.DeleteSelection();
Check("레이어: 멤버 1개 남으면 그룹 자동 해제",
!lay.LayerRows.OfType<LayerGroupViewModel>().Any()
&& lay.Pages[0].Controls.All(c => c.Model.GroupId is null)
&& lay.Document.GroupNames.Count == 0);
// 그룹은 편집 전용 상태다 — 저장 XML 에 흔적이 남으면 왕복 불변식이 깨진다
lay.Selection.Set(lay.Pages[0].Controls.Take(2).ToList());
lay.GroupSelection();
Check("레이어: 그룹은 XML 에 나가지 않음",
!serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase));
}
// 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유) // 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유)
var designerB = new DesignerViewModel(business.CreateNew()); var designerB = new DesignerViewModel(business.CreateNew());
designer.Selection.SetSingle(Find("TextBox")); designer.Selection.SetSingle(Find("TextBox"));
@@ -682,6 +682,28 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
<!--
접기/펼치기 셰브론 — 켜짐(=펼침)이 기본 상태라 IsChecked 채움이 들어가면 항상 눌린 것처럼 보인다.
방향이 이미 상태를 말해 주므로 채움은 빼고 호버 반응만 남긴다.
-->
<Style x:Key="MiniChevron" TargetType="ToggleButton" BasedOn="{StaticResource MiniToggle}">
<Setter Property="Opacity" Value="1" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="bd" Background="Transparent" CornerRadius="5" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 체크박스 ===== --> <!-- ===== 체크박스 ===== -->
<Style TargetType="CheckBox"> <Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" /> <Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
@@ -15,6 +15,8 @@ public abstract class ControlViewModel : ViewModelBase
#region Member Fields #region Member Fields
private bool isSelected; private bool isSelected;
private bool isHovered; private bool isHovered;
private double layerIndent;
private bool isLayerExpanded = true;
#endregion #endregion
#region Properties #region Properties
@@ -172,6 +174,39 @@ public abstract class ControlViewModel : ViewModelBase
/// <summary>레이어 숨김 토글 아이콘명</summary> /// <summary>레이어 숨김 토글 아이콘명</summary>
public string HiddenIcon => Model.Hidden ? "eye-off" : "eye"; public string HiddenIcon => Model.Hidden ? "eye-off" : "eye";
/// <summary>
/// 레이어 목록 들여쓰기(px) — 그룹 멤버·컨테이너 자식의 계층을 나타낸다.
/// 목록이 평탄한 ListBox 라(TreeView 아님) 깊이를 여백으로만 표현한다 — 평탄화 시 대입된다.
/// </summary>
public double LayerIndent
{
get => layerIndent;
set => SetProperty(ref layerIndent, value);
}
/// <summary>펼침 토글 통지 — 목록을 다시 만들라는 신호(평탄화 때 디자이너가 배선한다)</summary>
public Action? LayerExpandedChanged { get; set; }
/// <summary>컨테이너(Panel/GroupBox) 자식 펼침 — 접으면 자식 행이 목록에서 빠진다</summary>
public bool IsLayerExpanded
{
get => isLayerExpanded;
set
{
if (SetProperty(ref isLayerExpanded, value))
{
OnPropertyChanged(nameof(LayerChevronIcon));
LayerExpandedChanged?.Invoke();
}
}
}
/// <summary>펼침 셰브론을 그릴지 — 자식이 있는 컨테이너에서만</summary>
public virtual bool HasLayerChildren => false;
/// <summary>펼침 셰브론 아이콘명</summary>
public string LayerChevronIcon => isLayerExpanded ? "chevron-down" : "chevron-right";
#endregion #endregion
#region Constructors #region Constructors
@@ -133,6 +133,9 @@ public abstract class ContainerViewModel : ControlViewModel
/// <summary>자식 컨트롤(그리기 순서 — 마지막이 최상위)</summary> /// <summary>자식 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
public ObservableCollection<ControlViewModel> Children { get; } = new(); public ObservableCollection<ControlViewModel> Children { get; } = new();
/// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary>
public override bool HasLayerChildren => Children.Count > 0;
protected ContainerViewModel(ControlElement model) : base(model) { } protected ContainerViewModel(ControlElement model) : base(model) { }
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary> /// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
@@ -74,6 +74,10 @@ public sealed class DesignerViewModel : ViewModelBase
private bool isTabOrderMode; private bool isTabOrderMode;
private readonly List<ControlViewModel> tabOrderSequence = new(); private readonly List<ControlViewModel> tabOrderSequence = new();
private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns; private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns;
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
private readonly HashSet<string> layerTypeFilter = new(StringComparer.Ordinal);
private System.Collections.Specialized.INotifyCollectionChanged? watchedControls;
#endregion #endregion
#region Properties #region Properties
@@ -147,11 +151,33 @@ public sealed class DesignerViewModel : ViewModelBase
{ {
Selection.ActivePage = value; Selection.ActivePage = value;
} }
OnPropertyChanged(nameof(VisibleLayers)); WatchLayerSource(value);
RebuildLayerRows();
} }
} }
} }
/// <summary>
/// 활성 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
/// (예전에는 <c>page.Controls</c> 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만,
/// 그룹 폴더가 끼면서 목록이 평탄화 결과가 됐다 — 원본이 변할 때 다시 만들어야 한다.)
/// </summary>
private void WatchLayerSource(PageViewModel? page)
{
if (watchedControls is not null)
{
watchedControls.CollectionChanged -= OnLayerSourceChanged;
}
watchedControls = page?.Controls;
if (watchedControls is not null)
{
watchedControls.CollectionChanged += OnLayerSourceChanged;
}
}
private void OnLayerSourceChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
=> RebuildLayerRows();
/// <summary> /// <summary>
/// 레이어 검색어 — 이름(LayerName)·Id 부분일치. 서식당 컨트롤이 수백 개인 경우가 흔해 /// 레이어 검색어 — 이름(LayerName)·Id 부분일치. 서식당 컨트롤이 수백 개인 경우가 흔해
/// (운영 최대 1,067개) 스크롤만으로는 대상을 찾을 수 없다. /// (운영 최대 1,067개) 스크롤만으로는 대상을 찾을 수 없다.
@@ -163,43 +189,36 @@ public sealed class DesignerViewModel : ViewModelBase
{ {
if (SetProperty(ref layerFilter, value ?? string.Empty)) if (SetProperty(ref layerFilter, value ?? string.Empty))
{ {
OnPropertyChanged(nameof(VisibleLayers)); RebuildLayerRows();
} }
} }
} }
/// <summary> /// <summary>
/// 레이어 목록에 실제로 표시할 항목활성 페이지의 컨트롤에 검색어를 적용한 결과. /// 레이어 목록에 실제로 표시할 그룹 폴더 행(<see cref="LayerGroupViewModel"/>)과
/// 검색어가 비면 원본 컬렉션을 그대로 돌려준다(항목 추가·삭제가 즉시 반영되도록). /// 컨트롤 행(<see cref="ControlViewModel"/>)이 섞인 <b>평탄화 결과</b>다.
///
/// TreeView 를 쓰지 않는 이유: 목록이 최대 1,067행이라 가상화가 필요하고, 컨트롤 행 템플릿
/// (눈·자물쇠·2줄 이름)을 이미 ListBox 기준으로 만들어 뒀다. 계층은 <c>LayerIndent</c> 여백으로만 표현한다
/// (인스펙터 섹션이 쓰는 방식과 같다).
/// </summary> /// </summary>
public System.Collections.IEnumerable? VisibleLayers public ObservableCollection<object> LayerRows { get; } = new();
{
get
{
var source = SelectedPage?.Controls;
if (source is null || layerFilter.Length == 0)
{
return source;
}
return source
.Where(c => c.LayerName.Contains(layerFilter, StringComparison.OrdinalIgnoreCase)
|| c.Id.Contains(layerFilter, StringComparison.OrdinalIgnoreCase))
.ToList();
}
}
/// <summary>레이어 목록 선택 항목 — 캔버스 선택과 동기</summary> /// <summary>검색·필터 결과가 0행인지 — 빈 목록에 안내를 띄우기 위한 플래그</summary>
public ControlViewModel? SelectedLayerItem public bool IsLayerListEmpty => LayerRows.Count == 0;
{
get => Selection.Primary; /// <summary>레이어 목록이 비어 보이는 이유</summary>
set public string LayerEmptyText =>
{ SelectedPage is null || SelectedPage.Controls.Count == 0
if (value is not null && value != Selection.Primary) ? "이 페이지에는 컨트롤이 없습니다"
{ : "검색·필터에 맞는 항목이 없습니다";
Selection.SetSingle(value);
} /// <summary>타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단</summary>
} public bool HasLayerTypeFilter => layerTypeFilter.Count > 0;
}
/// <summary>타입 필터 상태 툴팁</summary>
public string LayerTypeFilterTip =>
layerTypeFilter.Count == 0 ? "타입으로 거르기" : $"{layerTypeFilter.Count}종만 표시 중";
/// <summary>줌 배율(0.25~3.0)</summary> /// <summary>줌 배율(0.25~3.0)</summary>
public double Zoom public double Zoom
@@ -248,7 +267,7 @@ public sealed class DesignerViewModel : ViewModelBase
Interaction = new InteractionController(this); Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this); Inspector = new InspectorViewModel(this);
Selection.Changed += RefreshOverlay; Selection.Changed += RefreshOverlay;
Selection.SetChanged += () => OnPropertyChanged(nameof(SelectedLayerItem)); Selection.SetChanged += LayerSelectionChanged;
UndoCommand = new Command((sender, e) => Undo.Undo()); UndoCommand = new Command((sender, e) => Undo.Undo());
RedoCommand = new Command((sender, e) => Undo.Redo()); RedoCommand = new Command((sender, e) => Undo.Redo());
@@ -363,6 +382,8 @@ public sealed class DesignerViewModel : ViewModelBase
OnPropertyChanged(nameof(WorldHeight)); OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay(); RefreshOverlay();
Inspector.Rebuild(); Inspector.Rebuild();
// SelectedPage 대입이 이미 목록을 만들지만, 페이지가 0개면 setter 가 발화하지 않는다
RebuildLayerRows();
} }
/// <summary>페이지 추가 — 문서 끝에 빈 페이지(720×856)</summary> /// <summary>페이지 추가 — 문서 끝에 빈 페이지(720×856)</summary>
@@ -946,6 +967,8 @@ public sealed class DesignerViewModel : ViewModelBase
childViewModel.Parent = container; childViewModel.Parent = container;
container.Children.Add(childViewModel); container.Children.Add(childViewModel);
Selection.SetSingle(childViewModel); Selection.SetSingle(childViewModel);
// 컨테이너 자식은 page.Controls 밖이라 CollectionChanged 가 오지 않는다
RebuildLayerRows();
return; return;
} }
@@ -996,6 +1019,8 @@ public sealed class DesignerViewModel : ViewModelBase
page.Model.Controls.Remove(vm.Model); page.Model.Controls.Remove(vm.Model);
} }
Selection.Clear(); Selection.Clear();
// 컨테이너 자식 삭제는 page.Controls 를 건드리지 않아 CollectionChanged 가 오지 않는다
RebuildLayerRows();
} }
/// <summary>선택 복사 — 내부 클립보드(딥클론, 모델 순서 유지)</summary> /// <summary>선택 복사 — 내부 클립보드(딥클론, 모델 순서 유지)</summary>
@@ -1044,17 +1069,44 @@ public sealed class DesignerViewModel : ViewModelBase
Undo.Snapshot(); Undo.Snapshot();
var pasted = new List<ControlViewModel>(); var pasted = new List<ControlViewModel>();
var usedIds = IdGenerator.CollectUsed(document); var usedIds = IdGenerator.CollectUsed(document);
// 그룹은 복제본끼리 다시 묶는다 — Clone 이 GroupId 를 그대로 복사하므로 그냥 두면
// 붙여넣은 컨트롤이 원본 그룹에 끼어들어 원본 하나만 클릭해도 복제본까지 함께 선택된다
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
// 모델 순서(0=최상위) 유지: 역순으로 index 0 삽입 // 모델 순서(0=최상위) 유지: 역순으로 index 0 삽입
foreach (var source in Enumerable.Reverse(pastable)) foreach (var source in Enumerable.Reverse(pastable))
{ {
var clone = source.Clone(); var clone = source.Clone();
AssignNewIds(clone, usedIds); AssignNewIds(clone, usedIds);
RemapGroupId(clone, groupRemap);
clone.Bounds.X += 12; clone.Bounds.X += 12;
clone.Bounds.Y += 12; clone.Bounds.Y += 12;
page.Model.Controls.Insert(0, clone); page.Model.Controls.Insert(0, clone);
pasted.Add(CreateAndAttach(clone, page, attachToEnd: true)); pasted.Add(CreateAndAttach(clone, page, attachToEnd: true));
} }
Selection.Set(pasted, pasted.LastOrDefault()); Selection.Set(pasted, pasted.LastOrDefault());
RebuildLayerRows();
}
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{
if (element.GroupId is { } original)
{
if (!remap.TryGetValue(original, out var replacement))
{
replacement = Guid.NewGuid().ToString("N");
remap[original] = replacement;
if (document.GroupNames.TryGetValue(original, out var name))
{
document.GroupNames[replacement] = name;
}
}
element.GroupId = replacement;
}
foreach (var child in element.Children)
{
RemapGroupId(child, remap);
}
} }
/// <summary> /// <summary>
@@ -1169,7 +1221,10 @@ public sealed class DesignerViewModel : ViewModelBase
Selection.NotifyBoundsChanged(); Selection.NotifyBoundsChanged();
} }
/// <summary>선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만)</summary> /// <summary>
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
/// </summary>
public void GroupSelection() public void GroupSelection()
{ {
var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList(); var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList();
@@ -1183,20 +1238,39 @@ public sealed class DesignerViewModel : ViewModelBase
{ {
vm.Model.GroupId = groupId; vm.Model.GroupId = groupId;
} }
// 이름이 없으면 목록에 "그룹"만 여러 개 뜬다 — 만들 때 번호를 붙여 준다
document.GroupNames[groupId] = $"그룹 {ActiveGroupIds().Count}";
RebuildLayerRows();
} }
/// <summary>선택 그룹 해제</summary> /// <summary>
/// 선택 그룹 해제 — 선택에 걸린 그룹의 <b>전 멤버</b>를 푼다(선택되지 않은 멤버 포함).
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
/// </summary>
public void UngroupSelection() public void UngroupSelection()
{ {
if (Selection.Items.Count == 0) var groupIds = Selection.Items
.Select(vm => vm.Model.GroupId)
.Where(id => id is not null)
.Distinct(StringComparer.Ordinal)
.ToList();
if (groupIds.Count == 0)
{ {
return; return;
} }
Undo.Snapshot(); Undo.Snapshot();
foreach (var vm in Selection.Items) foreach (var control in AllControls())
{ {
vm.Model.GroupId = null; if (control.Model.GroupId is { } id && groupIds.Contains(id, StringComparer.Ordinal))
{
control.Model.GroupId = null;
}
} }
foreach (var id in groupIds)
{
document.GroupNames.Remove(id!);
}
RebuildLayerRows();
} }
/// <summary>그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)</summary> /// <summary>그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)</summary>
@@ -1213,6 +1287,253 @@ public sealed class DesignerViewModel : ViewModelBase
} }
return page.Controls.Where(c => c.Model.GroupId == groupId).ToList(); return page.Controls.Where(c => c.Model.GroupId == groupId).ToList();
} }
#endregion
#region Methods -
/// <summary>그룹 표시 이름 — 없으면 기본값("그룹")</summary>
public string GroupNameOf(string groupId)
=> document.GroupNames.TryGetValue(groupId, out var name) && name.Trim().Length > 0
? name
: LayerGroupViewModel.FallbackName;
/// <summary>그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다</summary>
public void RenameGroup(string groupId, string? name)
{
var trimmed = (name ?? string.Empty).Trim();
if (trimmed.Length == 0 || trimmed == LayerGroupViewModel.FallbackName)
{
document.GroupNames.Remove(groupId);
return;
}
Undo.Snapshot();
document.GroupNames[groupId] = trimmed;
}
/// <summary>그룹 펼침 상태 — 기본은 펼침</summary>
public bool IsGroupExpanded(string groupId) => !collapsedGroups.Contains(groupId);
/// <summary>그룹 펼침/접기 — 목록을 다시 만든다</summary>
public void SetGroupExpanded(string groupId, bool expanded)
{
if (expanded ? collapsedGroups.Remove(groupId) : collapsedGroups.Add(groupId))
{
RequestLayerRebuild();
}
}
/// <summary>
/// 목록 재구성 예약 — 펼침 토글은 <b>목록 안의 버튼</b>이 일으킨다. 즉시 재구성하면 클릭 이벤트가
/// 끝나기도 전에 그 버튼이 목록에서 떨어져 나가므로 한 틱 미뤄 안전하게 갈아 끼운다.
/// (진단 모드처럼 Application 이 없으면 그냥 즉시 재구성한다)
/// </summary>
public void RequestLayerRebuild()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null)
{
RebuildLayerRows();
return;
}
dispatcher.BeginInvoke(new Action(RebuildLayerRows));
}
/// <summary>현재 문서에 살아 있는 그룹 식별자</summary>
public HashSet<string> ActiveGroupIds()
{
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id)
{
ids.Add(id);
}
}
return ids;
}
/// <summary>
/// 그룹 자가치유 — 멤버가 1개 이하로 줄어든 그룹을 풀고, 주인 없는 이름을 지운다.
/// 삭제·Undo·페이지 이동 뒤에 홀로 남은 그룹은 클릭 확장이 무의미하고 폴더만 어지럽힌다.
/// 편집 의도가 아니라 정리라서 Undo 스냅샷을 찍지 않는다.
/// </summary>
public void CleanupGroups()
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id)
{
counts[id] = counts.TryGetValue(id, out var n) ? n + 1 : 1;
}
}
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id && counts[id] < 2)
{
control.Model.GroupId = null;
}
}
foreach (var orphan in document.GroupNames.Keys
.Where(id => !counts.TryGetValue(id, out var n) || n < 2).ToList())
{
document.GroupNames.Remove(orphan);
}
collapsedGroups.RemoveWhere(id => !counts.ContainsKey(id));
}
/// <summary>타입 필터 — 비어 있으면 전체 표시</summary>
public IReadOnlyCollection<string> LayerTypeFilter => layerTypeFilter;
/// <summary>타입 필터 교체</summary>
public void SetLayerTypeFilter(IEnumerable<string> types)
{
layerTypeFilter.Clear();
foreach (var type in types)
{
layerTypeFilter.Add(type);
}
OnPropertyChanged(nameof(HasLayerTypeFilter));
OnPropertyChanged(nameof(LayerTypeFilterTip));
RebuildLayerRows();
}
/// <summary>활성 페이지에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순</summary>
public List<(string Type, int Count)> LayerTypeCandidates()
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
void Visit(IEnumerable<ControlViewModel> items)
{
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);
}
}
}
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>를 그대로 따른다(캔버스에서 뒤에 있는 것이 목록 위).
/// </summary>
public void RebuildLayerRows()
{
CleanupGroups();
IsRebuildingLayers = true;
LayerRows.Clear();
var page = SelectedPage;
if (page is not null)
{
var rendered = new HashSet<string>(StringComparer.Ordinal);
foreach (var control in page.Controls)
{
if (control.Model.GroupId is { } groupId)
{
if (!rendered.Add(groupId))
{
continue;
}
var members = page.Controls.Where(c => c.Model.GroupId == groupId).ToList();
var shown = members.Where(PassesLayerFilter).ToList();
if (shown.Count == 0)
{
continue;
}
var group = new LayerGroupViewModel(this, groupId, members);
LayerRows.Add(group);
if (group.IsExpanded)
{
foreach (var member in shown)
{
AddLayerRow(member, 20);
}
}
continue;
}
if (PassesLayerFilter(control))
{
AddLayerRow(control, 0);
}
}
}
IsRebuildingLayers = false;
OnPropertyChanged(nameof(IsLayerListEmpty));
OnPropertyChanged(nameof(LayerEmptyText));
LayerRowsRebuilt?.Invoke();
}
/// <summary>
/// 목록 재구성 중 — 이때 ListBox 가 쏟아내는 선택 해제는 사용자의 조작이 아니다.
/// (<c>LayerRows.Clear()</c> 가 SelectedItems 를 비우면서 SelectionChanged 를 발화한다)
/// </summary>
public bool IsRebuildingLayers { get; private set; }
/// <summary>레이어 목록 재구성 완료 — 목록 강조를 되살리라는 신호</summary>
public event Action? LayerRowsRebuilt;
/// <summary>컨트롤 행 + (펼쳐져 있으면) 컨테이너 자식 행을 재귀로 추가</summary>
private void AddLayerRow(ControlViewModel viewModel, double indent)
{
viewModel.LayerIndent = indent;
viewModel.LayerExpandedChanged ??= RequestLayerRebuild;
LayerRows.Add(viewModel);
if (viewModel is not ContainerViewModel container || !viewModel.IsLayerExpanded)
{
return;
}
foreach (var child in container.Children.Where(PassesLayerFilter))
{
AddLayerRow(child, indent + 16);
}
}
/// <summary>
/// 검색어·타입 필터 통과 판정 — 자손이 걸리면 조상도 통과시킨다.
/// 그러지 않으면 패널 안의 컨트롤을 검색했을 때 부모가 빠져 자식이 통째로 사라진다.
/// </summary>
private bool PassesLayerFilter(ControlViewModel viewModel)
{
if (Matches(viewModel))
{
return true;
}
return viewModel is ContainerViewModel container && container.Children.Any(PassesLayerFilter);
bool Matches(ControlViewModel vm)
{
if (layerTypeFilter.Count > 0 && !layerTypeFilter.Contains(vm.Type))
{
return false;
}
return layerFilter.Length == 0
|| vm.LayerName.Contains(layerFilter, StringComparison.OrdinalIgnoreCase)
|| vm.Id.Contains(layerFilter, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>선택 집합이 바뀌었을 때 — 그룹 폴더의 눈·자물쇠 표시를 다시 계산</summary>
private void LayerSelectionChanged()
{
foreach (var group in LayerRows.OfType<LayerGroupViewModel>())
{
group.NotifyFlagsChanged();
}
}
/// <summary>레이어 행(컨트롤 또는 그룹 폴더) → 선택 대상 컨트롤 목록</summary>
public static IEnumerable<ControlViewModel> ControlsOfLayerRow(object? row) => row switch
{
ControlViewModel control => new[] { control },
LayerGroupViewModel group => group.Members,
_ => Enumerable.Empty<ControlViewModel>(),
};
/// <summary>잘라내기 — 복사 후 삭제</summary> /// <summary>잘라내기 — 복사 후 삭제</summary>
public void CutSelection() public void CutSelection()
@@ -0,0 +1,127 @@
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.ViewModels;
/// <summary>
/// 레이어 목록의 그룹 폴더 행 1개 — <see cref="SheetMe.Core.Models.ControlElement.GroupId"/> 하나에 대응.
///
/// 그룹은 컨테이너가 아니라 <b>같은 GroupId 를 가진 최상위 컨트롤들에 붙은 태그</b>다.
/// 따라서 이 VM 은 문서 구조에 존재하지 않는 <b>표시 전용</b> 행이며, 목록을 다시 평탄화할 때마다
/// 새로 만들어진다 — 펼침 상태·이름은 여기에 두지 않고 각각
/// <see cref="DesignerViewModel"/> 의 접힌 그룹 집합과 <c>FormDocument.GroupNames</c> 가 소유한다.
/// </summary>
public sealed class LayerGroupViewModel : ViewModelBase
{
#region Member Fields
private const string DefaultName = "그룹";
private readonly DesignerViewModel designer;
private bool isEditing;
#endregion
#region Properties
/// <summary>그룹 식별자</summary>
public string GroupId { get; }
/// <summary>이 그룹에 속한 컨트롤(활성 페이지 기준, 검색 필터 적용 전)</summary>
public IReadOnlyList<ControlViewModel> Members { get; }
/// <summary>
/// 그룹 표시 이름 — 비우면 문서에서 키를 지워 기본값("그룹")으로 되돌아간다.
/// 값의 소유자는 문서(GroupNames)이고 이 프로퍼티는 통로일 뿐이다.
/// </summary>
public string Name
{
get => designer.GroupNameOf(GroupId);
set
{
designer.RenameGroup(GroupId, value);
OnPropertyChanged(nameof(Name));
}
}
/// <summary>멤버 수 표시 — "(3)"</summary>
public string MemberCountText => $"({Members.Count})";
/// <summary>펼침 상태 — 접으면 멤버 행이 목록에서 빠진다</summary>
public bool IsExpanded
{
get => designer.IsGroupExpanded(GroupId);
set
{
designer.SetGroupExpanded(GroupId, value);
OnPropertyChanged(nameof(IsExpanded));
OnPropertyChanged(nameof(ChevronIcon));
}
}
/// <summary>펼침 셰브론 아이콘명</summary>
public string ChevronIcon => IsExpanded ? "chevron-down" : "chevron-right";
/// <summary>
/// 그룹 일괄 숨김 — 하나라도 보이면 전부 숨기고, 전부 숨겨져 있으면 전부 되살린다.
/// 혼합 상태를 표현하는 3상태 배지는 두지 않는다([200] 과 동일 — 한 번 더 누르면 정리된다).
/// </summary>
public bool IsHiddenFlag
{
get => Members.Count > 0 && Members.All(m => m.IsHiddenFlag);
set
{
foreach (var member in Members)
{
member.IsHiddenFlag = value;
}
NotifyFlagsChanged();
}
}
/// <summary>그룹 일괄 잠금</summary>
public bool IsLockedFlag
{
get => Members.Count > 0 && Members.All(m => m.IsLockedFlag);
set
{
foreach (var member in Members)
{
member.IsLockedFlag = value;
}
NotifyFlagsChanged();
}
}
/// <summary>잠금 아이콘명 — 하나라도 잠겨 있으면 잠긴 모양</summary>
public string LockIcon => Members.Any(m => m.IsLockedFlag) ? "lock" : "unlock";
/// <summary>숨김 아이콘명 — 하나라도 숨겨져 있으면 숨긴 모양</summary>
public string HiddenIcon => Members.Any(m => m.IsHiddenFlag) ? "eye-off" : "eye";
/// <summary>이름 인라인 편집 중 — 더블클릭으로 켠다</summary>
public bool IsEditing
{
get => isEditing;
set => SetProperty(ref isEditing, value);
}
#endregion
#region Constructors
public LayerGroupViewModel(DesignerViewModel designer, string groupId, IReadOnlyList<ControlViewModel> members)
{
this.designer = designer;
GroupId = groupId;
Members = members;
}
#endregion
#region Methods
/// <summary>멤버 플래그가 밖에서 바뀌었을 때 아이콘·토글 상태 재계산</summary>
public void NotifyFlagsChanged()
{
OnPropertyChanged(nameof(IsHiddenFlag));
OnPropertyChanged(nameof(IsLockedFlag));
OnPropertyChanged(nameof(HiddenIcon));
OnPropertyChanged(nameof(LockIcon));
}
/// <summary>이름이 없을 때의 기본 표시</summary>
public static string FallbackName => DefaultName;
#endregion
}
@@ -0,0 +1,36 @@
<Window x:Class="SheetMe.Designer.Views.LayerTypeFilterDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="타입으로 거르기" Width="380" Height="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:TypeToIconConverter x:Key="TypeToIcon"/>
</Window.Resources>
<DockPanel Margin="12">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="이 페이지에 실제로 있는 타입만 보여 줍니다. 아무것도 고르지 않으면 전체가 표시됩니다."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="전체 해제" Padding="12,4" Click="OnClearAll"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<ListBox x:Name="TypeList" SelectionMode="Multiple">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2">
<TextBlock DockPanel.Dock="Right" Text="{Binding CountText}" FontSize="11"
VerticalAlignment="Center" Foreground="{DynamicResource B.Muted}"/>
<ContentControl DockPanel.Dock="Left" Margin="2,0,8,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding Type, Converter={StaticResource TypeToIcon}, ConverterParameter=15|B.Accent}"/>
<TextBlock Text="{Binding Type}" FontSize="12.5" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Window>
@@ -0,0 +1,51 @@
using System.Windows;
namespace SheetMe.Designer.Views;
/// <summary>
/// 레이어 목록 타입 필터 — "체크박스만" 처럼 컨트롤 종류로 목록을 좁힌다.
///
/// 후보를 팔레트 전체가 아니라 <b>활성 페이지에 실제로 있는 타입</b>으로 한정한다:
/// 서식 하나에 쓰이는 타입은 보통 5~8종이라 팔레트 16종을 다 늘어놓으면 없는 타입만 고르게 된다.
/// </summary>
public partial class LayerTypeFilterDialog : Window
{
#region Types
/// <summary>후보 1행 — 타입명과 그 페이지에서의 개수</summary>
private sealed record TypeRow(string Type, int Count)
{
public string CountText => $"{Count}개";
}
#endregion
#region Properties
/// <summary>확정된 타입 집합 — 비어 있으면 전체 표시</summary>
public IReadOnlyList<string> SelectedTypes { get; private set; } = Array.Empty<string>();
#endregion
#region Constructors
public LayerTypeFilterDialog(IEnumerable<(string Type, int Count)> candidates, IEnumerable<string> current)
{
InitializeComponent();
var rows = candidates.Select(c => new TypeRow(c.Type, c.Count)).ToList();
TypeList.ItemsSource = rows;
var selected = new HashSet<string>(current, StringComparer.Ordinal);
foreach (var row in rows.Where(r => selected.Contains(r.Type)))
{
TypeList.SelectedItems.Add(row);
}
}
#endregion
#region Methods
private void OnClearAll(object sender, RoutedEventArgs e) => TypeList.SelectedItems.Clear();
private void OnConfirm(object sender, RoutedEventArgs e)
{
SelectedTypes = TypeList.SelectedItems.Cast<TypeRow>().Select(r => r.Type).ToList();
DialogResult = true;
}
#endregion
}
+124 -48
View File
@@ -507,6 +507,15 @@
<DockPanel> <DockPanel>
<ContentControl DockPanel.Dock="Left" Margin="9,0,7,0" VerticalAlignment="Center" IsTabStop="False" <ContentControl DockPanel.Dock="Left" Margin="9,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding Source=search, Converter={StaticResource IconName}, ConverterParameter=13}"/> 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> <Grid>
<TextBox x:Name="LayerSearchBox" MinHeight="0" Padding="0" BorderThickness="0" <TextBox x:Name="LayerSearchBox" MinHeight="0" Padding="0" BorderThickness="0"
Background="Transparent" VerticalContentAlignment="Center" Background="Transparent" VerticalContentAlignment="Center"
@@ -528,54 +537,121 @@
</DockPanel> </DockPanel>
</Border> </Border>
<!-- 레이어 목록 (활성 페이지, 그리기 순서) --> <!--
<ListBox x:Name="LayerListBox" ItemsSource="{Binding CurrentDesigner.VisibleLayers}" 레이어 목록 (활성 페이지, 그리기 순서).
SelectedItem="{Binding CurrentDesigner.SelectedLayerItem}" 그룹 폴더 행과 컨트롤 행이 한 시퀀스로 섞이는 평탄화 목록이라
MouseDoubleClick="OnLayerDoubleClick" ItemTemplate 하나로는 안 되고(암시 DataType 매칭은 LabelViewModel 같은
ToolTip="더블클릭하면 캔버스가 그 컨트롤로 이동합니다" 구체 형에만 걸린다) 선택기로 가른다. 계층은 LayerIndent 여백으로만 표현.
Margin="4,0,4,6" -->
ScrollViewer.HorizontalScrollBarVisibility="Disabled"> <Grid Margin="4,0,4,6">
<ListBox.ItemTemplate> <ListBox x:Name="LayerListBox" ItemsSource="{Binding CurrentDesigner.LayerRows}"
<DataTemplate> bh:LayerListBehavior.Designer="{Binding CurrentDesigner}"
<DockPanel Margin="0,1"> SelectionMode="Extended"
<!-- 눈·자물쇠는 아이콘이 상태에 따라 바뀐다 — 사각 체크로는 잠김/숨김이 안 읽힌다 --> MouseDoubleClick="OnLayerDoubleClick"
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}" ToolTip="더블클릭 — 컨트롤은 캔버스 이동, 그룹은 이름 바꾸기"
IsChecked="{Binding IsLockedFlag}" ToolTip="잠금" ScrollViewer.HorizontalScrollBarVisibility="Disabled">
Content="{Binding LockIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/> <ListBox.ItemTemplateSelector>
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}" <ctl:LayerRowTemplateSelector>
IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김" <!-- 그룹 폴더 행 — 셰브론 / 폴더 / 이름(더블클릭 편집) / (n) / 일괄 눈·자물쇠 -->
Content="{Binding HiddenIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/> <ctl:LayerRowTemplateSelector.GroupTemplate>
<ContentControl DockPanel.Dock="Left" Margin="2,0,6,0" VerticalAlignment="Center" IsTabStop="False" <DataTemplate>
Content="{Binding Type, Converter={StaticResource TypeToIcon}, ConverterParameter=14|B.Accent}"/> <DockPanel Margin="0,1">
<!-- <ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
문구와 이름을 두 줄로. 컨트롤 이름(Id)은 외부 참조 키라 IsChecked="{Binding IsLockedFlag}" ToolTip="그룹 전체 잠금"
(액션 대상·입력 파라미터가 이름으로 서로를 가리키고 CalcBox 수식도 이름으로 합산한다) Content="{Binding LockIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/>
배선하려면 목록에서 보여야 하는데, 248px 폭에 나란히 두면 양쪽 다 잘려 <ToggleButton DockPanel.Dock="Right" Style="{StaticResource MiniToggle}"
'txtKinPhone…' 처럼 구분이 안 되는 꼬리가 남는다. 한 줄 더 쓰는 편이 낫다. IsChecked="{Binding IsHiddenFlag}" ToolTip="그룹 전체 숨김"
표시 문구가 없어 이름이 곧 문구인 행에서는 둘째 줄이 뜨지 않는다. Content="{Binding HiddenIcon, Converter={StaticResource IconName}, ConverterParameter=13}"/>
--> <ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="18"
<StackPanel VerticalAlignment="Center"> IsChecked="{Binding IsExpanded}" ToolTip="접기/펼치기"
<StackPanel.Style> Content="{Binding ChevronIcon, Converter={StaticResource IconName}, ConverterParameter=12}"/>
<Style TargetType="StackPanel"> <ContentControl DockPanel.Dock="Left" Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False"
<Style.Triggers> Content="{Binding Source=folder, Converter={StaticResource IconName}, ConverterParameter=14|B.Accent}"/>
<DataTrigger Binding="{Binding IsHiddenFlag}" Value="True"> <TextBlock DockPanel.Dock="Right" Text="{Binding MemberCountText}" FontSize="11"
<Setter Property="Opacity" Value="0.55"/> VerticalAlignment="Center" Margin="4,0,4,0"
</DataTrigger> Foreground="{DynamicResource B.Muted}"/>
</Style.Triggers> <Grid VerticalAlignment="Center">
</Style> <TextBlock Text="{Binding Name}" FontSize="12" FontWeight="SemiBold"
</StackPanel.Style> TextTrimming="CharacterEllipsis"
<TextBlock Text="{Binding LayerName}" FontSize="12" ToolTip="더블클릭하면 그룹 이름을 바꿉니다">
TextTrimming="CharacterEllipsis" ToolTip="{Binding LayerName}"/> <TextBlock.Style>
<TextBlock Text="{Binding LayerId}" FontSize="10.5" Margin="0,1,0,0" <Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
Foreground="{DynamicResource B.Muted}" <Style.Triggers>
TextTrimming="CharacterEllipsis" <DataTrigger Binding="{Binding IsEditing}" Value="True">
ToolTip="컨트롤 이름 — 다른 속성이 이 이름으로 참조합니다" <Setter Property="Visibility" Value="Collapsed"/>
Visibility="{Binding HasLayerId, Converter={StaticResource BoolToVisibility}}"/> </DataTrigger>
</StackPanel> </Style.Triggers>
</DockPanel> </Style>
</DataTemplate> </TextBlock.Style>
</ListBox.ItemTemplate> </TextBlock>
</ListBox> <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>
</DockPanel> </DockPanel>
</TabItem> </TabItem>
+90 -4
View File
@@ -39,19 +39,105 @@ public partial class MainView : Window
} }
/// <summary> /// <summary>
/// 레이어 더블클릭 → 캔버스를 그 컨트롤로 이동. /// 레이어 더블클릭 → 컨트롤은 캔버스 이동, 그룹 폴더는 이름 편집 시작.
/// 컨트롤이 수백 개인 서식에서는 목록에서 이름을 찾아도 종이 어디에 있는지 알 수 없었다. /// 컨트롤이 수백 개인 서식에서는 목록에서 이름을 찾아도 종이 어디에 있는지 알 수 없었다.
/// (ListBox 더블클릭은 InputBinding 을 지원하지 않아 코드비하인드로 위임한다 — 서식 목록과 같은 이유) /// (ListBox 더블클릭은 InputBinding 을 지원하지 않아 코드비하인드로 위임한다 — 서식 목록과 같은 이유)
/// </summary> /// </summary>
private void OnLayerDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) private void OnLayerDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{ {
if (DataContext is MainViewModel { CurrentDesigner: { } designer } if (DataContext is not MainViewModel { CurrentDesigner: { } designer })
&& LayerListBox.SelectedItem is ViewModels.Controls.ControlViewModel target)
{ {
designer.RevealControl(target); 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) private void OnThemeToggleClick(object sender, RoutedEventArgs e)
{ {
ThemeManager.Toggle(); ThemeManager.Toggle();