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 ExpectedRows(ListBox list, DesignerViewModel designer) + { + var selected = designer.Selection.Items; + var rows = new List(); + 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 +} diff --git a/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs b/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs new file mode 100644 index 0000000..b57e27b --- /dev/null +++ b/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs @@ -0,0 +1,24 @@ +using System.Windows; +using System.Windows.Controls; +using SheetMe.Designer.ViewModels; + +namespace SheetMe.Designer.Controls; + +/// +/// 레이어 목록 행 템플릿 선택 — 그룹 폴더 행과 컨트롤 행을 가른다. +/// +/// DataTemplate DataType 암시 매칭을 쓸 수 없다: WPF 는 정확한 런타임 형으로만 찾는데 +/// 컨트롤 행의 실제 형은 LabelViewModel·TextBoxViewModel 처럼 제각각이고 +/// 공통 기반인 ControlViewModel 로는 매칭되지 않는다. +/// +public sealed class LayerRowTemplateSelector : DataTemplateSelector +{ + /// 그룹 폴더 행 + public DataTemplate? GroupTemplate { get; set; } + + /// 컨트롤 행 + public DataTemplate? ControlTemplate { get; set; } + + public override DataTemplate? SelectTemplate(object? item, DependencyObject container) + => item is LayerGroupViewModel ? GroupTemplate : ControlTemplate; +} diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs index aeebeec..2b39150 100644 --- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs +++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs @@ -126,6 +126,8 @@ public static class DialogShots ("10-inspector-textbox", () => HostInspector(designer, "TextBox1")), ("11-inspector-data", () => HostInspector(designer, "TextBox1", InspectorTab.Data)), ("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)), + ("13-layer-type-filter", () => new Views.LayerTypeFilterDialog( + designer.LayerTypeCandidates(), new[] { "Label" })), }; } diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index c6f0812..492f143 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -261,6 +261,118 @@ public static class EditSmoke Check("폰트 일괄 적용: Undo 1스텝 원복", 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().FirstOrDefault(); + Check("레이어: 그룹 폴더 행 1개", lay.LayerRows.OfType().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().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().First(); + groupRow.IsExpanded = true; + lay.RebuildLayerRows(); + Check("레이어: 펴면 멤버 행 복귀", lay.LayerRows.Count == expandedCount); + + // 그룹 일괄 숨김이 전 멤버에 퍼진다 + groupRow = lay.LayerRows.OfType().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().First(); + lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20)); + lay.RebuildLayerRows(); + Check("레이어: 컨테이너 자식이 목록에 나옴", + lay.LayerRows.OfType().Any(c => ReferenceEquals(c.Parent, layerPanel))); + Check("레이어: 자식 들여쓰기 = 부모+16", + lay.LayerRows.OfType() + .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().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().Any() + && lay.LayerRows.OfType().All(c => c.Type == "CheckBox")); + lay.SetLayerTypeFilter(Array.Empty()); + Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter); + + // 붙여넣은 복제본은 원본 그룹에 끼지 않는다(GroupId 재매핑) + groupRow = lay.LayerRows.OfType().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().First(); + lay.Selection.SetSingle(groupRow.Members[1]); + lay.DeleteSelection(); + Check("레이어: 멤버 1개 남으면 그룹 자동 해제", + !lay.LayerRows.OfType().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 서식에 붙여넣기(클립보드 공유) var designerB = new DesignerViewModel(business.CreateNew()); designer.Selection.SetSingle(Find("TextBox")); diff --git a/src/SheetMe.Designer/Themes/DesignerTheme.xaml b/src/SheetMe.Designer/Themes/DesignerTheme.xaml index e251d2f..bff02db 100644 --- a/src/SheetMe.Designer/Themes/DesignerTheme.xaml +++ b/src/SheetMe.Designer/Themes/DesignerTheme.xaml @@ -682,6 +682,28 @@ + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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();