레이어 패널에 [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
@@ -74,6 +74,10 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
private bool isTabOrderMode;
|
||||
private readonly List<ControlViewModel> tabOrderSequence = new();
|
||||
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
|
||||
|
||||
#region Properties
|
||||
@@ -147,11 +151,33 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
{
|
||||
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>
|
||||
/// 레이어 검색어 — 이름(LayerName)·Id 부분일치. 서식당 컨트롤이 수백 개인 경우가 흔해
|
||||
/// (운영 최대 1,067개) 스크롤만으로는 대상을 찾을 수 없다.
|
||||
@@ -163,43 +189,36 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
{
|
||||
if (SetProperty(ref layerFilter, value ?? string.Empty))
|
||||
{
|
||||
OnPropertyChanged(nameof(VisibleLayers));
|
||||
RebuildLayerRows();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 목록에 실제로 표시할 항목 — 활성 페이지의 컨트롤에 검색어를 적용한 결과.
|
||||
/// 검색어가 비면 원본 컬렉션을 그대로 돌려준다(항목 추가·삭제가 즉시 반영되도록).
|
||||
/// 레이어 목록에 실제로 표시할 행 — 그룹 폴더 행(<see cref="LayerGroupViewModel"/>)과
|
||||
/// 컨트롤 행(<see cref="ControlViewModel"/>)이 섞인 <b>평탄화 결과</b>다.
|
||||
///
|
||||
/// TreeView 를 쓰지 않는 이유: 목록이 최대 1,067행이라 가상화가 필요하고, 컨트롤 행 템플릿
|
||||
/// (눈·자물쇠·2줄 이름)을 이미 ListBox 기준으로 만들어 뒀다. 계층은 <c>LayerIndent</c> 여백으로만 표현한다
|
||||
/// (인스펙터 섹션이 쓰는 방식과 같다).
|
||||
/// </summary>
|
||||
public System.Collections.IEnumerable? VisibleLayers
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
public ObservableCollection<object> LayerRows { get; } = new();
|
||||
|
||||
/// <summary>레이어 목록 선택 항목 — 캔버스 선택과 동기</summary>
|
||||
public ControlViewModel? SelectedLayerItem
|
||||
{
|
||||
get => Selection.Primary;
|
||||
set
|
||||
{
|
||||
if (value is not null && value != Selection.Primary)
|
||||
{
|
||||
Selection.SetSingle(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>검색·필터 결과가 0행인지 — 빈 목록에 안내를 띄우기 위한 플래그</summary>
|
||||
public bool IsLayerListEmpty => LayerRows.Count == 0;
|
||||
|
||||
/// <summary>레이어 목록이 비어 보이는 이유</summary>
|
||||
public string LayerEmptyText =>
|
||||
SelectedPage is null || SelectedPage.Controls.Count == 0
|
||||
? "이 페이지에는 컨트롤이 없습니다"
|
||||
: "검색·필터에 맞는 항목이 없습니다";
|
||||
|
||||
/// <summary>타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단</summary>
|
||||
public bool HasLayerTypeFilter => layerTypeFilter.Count > 0;
|
||||
|
||||
/// <summary>타입 필터 상태 툴팁</summary>
|
||||
public string LayerTypeFilterTip =>
|
||||
layerTypeFilter.Count == 0 ? "타입으로 거르기" : $"{layerTypeFilter.Count}종만 표시 중";
|
||||
|
||||
/// <summary>줌 배율(0.25~3.0)</summary>
|
||||
public double Zoom
|
||||
@@ -248,7 +267,7 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
Interaction = new InteractionController(this);
|
||||
Inspector = new InspectorViewModel(this);
|
||||
Selection.Changed += RefreshOverlay;
|
||||
Selection.SetChanged += () => OnPropertyChanged(nameof(SelectedLayerItem));
|
||||
Selection.SetChanged += LayerSelectionChanged;
|
||||
|
||||
UndoCommand = new Command((sender, e) => Undo.Undo());
|
||||
RedoCommand = new Command((sender, e) => Undo.Redo());
|
||||
@@ -363,6 +382,8 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(WorldHeight));
|
||||
RefreshOverlay();
|
||||
Inspector.Rebuild();
|
||||
// SelectedPage 대입이 이미 목록을 만들지만, 페이지가 0개면 setter 가 발화하지 않는다
|
||||
RebuildLayerRows();
|
||||
}
|
||||
|
||||
/// <summary>페이지 추가 — 문서 끝에 빈 페이지(720×856)</summary>
|
||||
@@ -946,6 +967,8 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
childViewModel.Parent = container;
|
||||
container.Children.Add(childViewModel);
|
||||
Selection.SetSingle(childViewModel);
|
||||
// 컨테이너 자식은 page.Controls 밖이라 CollectionChanged 가 오지 않는다
|
||||
RebuildLayerRows();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -996,6 +1019,8 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
page.Model.Controls.Remove(vm.Model);
|
||||
}
|
||||
Selection.Clear();
|
||||
// 컨테이너 자식 삭제는 page.Controls 를 건드리지 않아 CollectionChanged 가 오지 않는다
|
||||
RebuildLayerRows();
|
||||
}
|
||||
|
||||
/// <summary>선택 복사 — 내부 클립보드(딥클론, 모델 순서 유지)</summary>
|
||||
@@ -1044,17 +1069,44 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
Undo.Snapshot();
|
||||
var pasted = new List<ControlViewModel>();
|
||||
var usedIds = IdGenerator.CollectUsed(document);
|
||||
// 그룹은 복제본끼리 다시 묶는다 — Clone 이 GroupId 를 그대로 복사하므로 그냥 두면
|
||||
// 붙여넣은 컨트롤이 원본 그룹에 끼어들어 원본 하나만 클릭해도 복제본까지 함께 선택된다
|
||||
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
// 모델 순서(0=최상위) 유지: 역순으로 index 0 삽입
|
||||
foreach (var source in Enumerable.Reverse(pastable))
|
||||
{
|
||||
var clone = source.Clone();
|
||||
AssignNewIds(clone, usedIds);
|
||||
RemapGroupId(clone, groupRemap);
|
||||
clone.Bounds.X += 12;
|
||||
clone.Bounds.Y += 12;
|
||||
page.Model.Controls.Insert(0, clone);
|
||||
pasted.Add(CreateAndAttach(clone, page, attachToEnd: true));
|
||||
}
|
||||
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>
|
||||
@@ -1169,7 +1221,10 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만)</summary>
|
||||
/// <summary>
|
||||
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
|
||||
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
|
||||
/// </summary>
|
||||
public void GroupSelection()
|
||||
{
|
||||
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;
|
||||
}
|
||||
// 이름이 없으면 목록에 "그룹"만 여러 개 뜬다 — 만들 때 번호를 붙여 준다
|
||||
document.GroupNames[groupId] = $"그룹 {ActiveGroupIds().Count}";
|
||||
RebuildLayerRows();
|
||||
}
|
||||
|
||||
/// <summary>선택 그룹 해제</summary>
|
||||
/// <summary>
|
||||
/// 선택 그룹 해제 — 선택에 걸린 그룹의 <b>전 멤버</b>를 푼다(선택되지 않은 멤버 포함).
|
||||
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
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>
|
||||
@@ -1213,6 +1287,253 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
}
|
||||
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>
|
||||
public void CutSelection()
|
||||
|
||||
Reference in New Issue
Block a user