레이어 패널에 [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
@@ -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
}