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