diff --git a/src/SheetMe.Core/Models/FormDocument.cs b/src/SheetMe.Core/Models/FormDocument.cs
index abcdc80..a135360 100644
--- a/src/SheetMe.Core/Models/FormDocument.cs
+++ b/src/SheetMe.Core/Models/FormDocument.cs
@@ -18,6 +18,16 @@ public sealed class FormDocument
/// 부가 메타(출처/감사/읽기 경고)
public FormMeta Meta { get; set; } = new();
+
+ ///
+ /// 디자이너 그룹 표시 이름(GroupId → 이름) — 직렬화 안 함(편집 전용 상태,
+ /// 와 수명이 같다).
+ ///
+ /// 이름이 없는 그룹은 목록에 "그룹"으로만 뜨는데, 한 서식에 그룹이 여럿이면 구분이 안 된다.
+ /// 값을 지우면(빈 문자열) 키를 제거해 기본 표시로 돌아간다.
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public Dictionary GroupNames { get; set; } = new(StringComparer.Ordinal);
#endregion
#region Methods
@@ -29,6 +39,7 @@ public sealed class FormDocument
FormId = FormId,
Title = Title,
Meta = Meta.Clone(),
+ GroupNames = new Dictionary(GroupNames, StringComparer.Ordinal),
};
foreach (var page in Pages)
{
diff --git a/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs b/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs
new file mode 100644
index 0000000..500febf
--- /dev/null
+++ b/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs
@@ -0,0 +1,159 @@
+using System.Windows;
+using System.Windows.Controls;
+using SheetMe.Designer.ViewModels;
+using SheetMe.Designer.ViewModels.Controls;
+
+namespace SheetMe.Designer.Behaviors;
+
+///
+/// 레이어 목록 ↔ 캔버스 선택 양방향 동기.
+///
+/// 목록을 SelectedItem 한 개로 바인딩하면 다중선택이 불가능하고(그룹 폴더 행을 고르면
+/// 형이 맞지 않아 바인딩이 조용히 실패한다), 반대로 선택 서비스만 진실로 두면 목록이 캔버스 선택을
+/// 따라오지 못한다. 그래서 SelectionMode=Extended 로 두고 여기서 양쪽을 잇는다.
+///
+/// 진실은 언제나 다 — 목록은 그 투영이며,
+/// 잠긴 컨트롤처럼 선택에서 걸러진 항목은 목록에서도 도로 풀린다.
+///
+public static class LayerListBehavior
+{
+ #region Attached Properties
+ /// 동기 대상 디자이너 — 문서 탭이 바뀌면 구독을 갈아탄다
+ 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 syncing = new();
+ private static readonly Dictionary 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);
+ }
+
+ /// 목록 → 선택 서비스. 그룹 폴더 행은 멤버 전체로 펼쳐서 넘긴다.
+ 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();
+ 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 의 범위 선택 기준점(anchor)이
+ // 매번 초기화돼 Shift+클릭·Shift+↓ 가 연속 범위가 아니라 띄엄띄엄 골라진다.
+ var expected = ExpectedRows(list, designer);
+ if (expected.Count != list.SelectedItems.Count
+ || expected.Any(row => !list.SelectedItems.Contains(row)))
+ {
+ SyncFromSelection(list, designer);
+ }
+ }
+
+ /// 선택 서비스 → 목록. 그룹 폴더는 멤버가 전부 선택됐을 때만 함께 강조한다.
+ 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);
+ }
+ }
+
+ /// 현재 선택으로부터 강조돼야 할 행들
+ private static List
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/MainView.xaml.cs b/src/SheetMe.Designer/Views/MainView.xaml.cs
index f7f69ab..f3bc944 100644
--- a/src/SheetMe.Designer/Views/MainView.xaml.cs
+++ b/src/SheetMe.Designer/Views/MainView.xaml.cs
@@ -39,19 +39,105 @@ public partial class MainView : Window
}
///
- /// 레이어 더블클릭 → 캔버스를 그 컨트롤로 이동.
+ /// 레이어 더블클릭 → 컨트롤은 캔버스 이동, 그룹 폴더는 이름 편집 시작.
/// 컨트롤이 수백 개인 서식에서는 목록에서 이름을 찾아도 종이 어디에 있는지 알 수 없었다.
/// (ListBox 더블클릭은 InputBinding 을 지원하지 않아 코드비하인드로 위임한다 — 서식 목록과 같은 이유)
///
private void OnLayerDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
- if (DataContext is MainViewModel { CurrentDesigner: { } designer }
- && LayerListBox.SelectedItem is ViewModels.Controls.ControlViewModel target)
+ if (DataContext is not MainViewModel { CurrentDesigner: { } designer })
{
- designer.RevealControl(target);
+ return;
+ }
+ switch (LayerListBox.SelectedItem)
+ {
+ case ViewModels.Controls.ControlViewModel target:
+ designer.RevealControl(target);
+ break;
+ case LayerGroupViewModel group:
+ group.IsEditing = true;
+ break;
}
}
+ ///
+ /// 그룹 이름 편집 상자가 보이게 된 순간 포커스+전체선택 — 더블클릭 직후 바로 덮어쓸 수 있게.
+ /// Loaded 로는 안 된다: 행이 만들어질 때 상자는 아직 접혀 있고, 나중에 펼쳐질 때 Loaded 는 다시 오지 않는다.
+ /// 표시가 실제로 반영된 뒤에 포커스를 줘야 하므로 한 틱 미룬다.
+ ///
+ 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);
+ }
+
+ /// Enter=확정 / Esc=취소 — 둘 다 편집 상태를 끈다
+ 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;
+ }
+ }
+
+ /// 포커스가 빠지면 확정(LostFocus 바인딩이 이미 값을 밀어 넣는다)
+ 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;
+ }
+ }
+
+ /// 레이어 타입 필터 — 활성 페이지에 실제로 있는 타입만 후보로 띄운다
+ 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);
+ }
+ }
+
+ /// 타입 필터 해제
+ private void OnLayerFilterClearClick(object sender, RoutedEventArgs e)
+ => (DataContext as MainViewModel)?.CurrentDesigner?.SetLayerTypeFilter(Array.Empty());
+
private void OnThemeToggleClick(object sender, RoutedEventArgs e)
{
ThemeManager.Toggle();