diff --git a/src/SheetMe.Designer/Behaviors/LayerDragBehavior.cs b/src/SheetMe.Designer/Behaviors/LayerDragBehavior.cs new file mode 100644 index 0000000..c7e5346 --- /dev/null +++ b/src/SheetMe.Designer/Behaviors/LayerDragBehavior.cs @@ -0,0 +1,262 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using SheetMe.Designer.ViewModels; +using SheetMe.Designer.ViewModels.Controls; + +namespace SheetMe.Designer.Behaviors; + +/// +/// 레이어 목록 드래그 재정렬 — 행을 끌어다 놓아 z-order(겹침 순서)를 바꾼다. +/// +/// 목록은 z-order 최상위가 맨 위이므로 위로 끌면 앞으로 나오고 아래로 끌면 뒤로 물러난다. +/// 대상은 같은 부모 안의 최상위 컨트롤끼리로 제한한다 — 컨테이너 안팎으로 옮기는 것은 +/// 좌표계가 바뀌는 별개의 연산이고, 그룹 폴더·페이지 행은 순서를 갖지 않는다. +/// +/// z-order 는 저장되는 값이라(XML Object 순서) 오작동이 실제 서식의 겹침을 망가뜨린다. +/// 그래서 드롭 위치 판정은 행 높이의 절반 기준으로 단순하게 두고, 삽입선을 눈으로 보여준다. +/// +public static class LayerDragBehavior +{ + #region Attached Properties + /// 드래그 재정렬을 붙일 디자이너 — 문서 탭이 바뀌면 갈아탄다 + public static readonly DependencyProperty DesignerProperty = DependencyProperty.RegisterAttached( + "Designer", typeof(DesignerViewModel), typeof(LayerDragBehavior), + 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 const double DragThreshold = 5; + + private static ListBox? activeList; + private static ControlViewModel? dragSource; + private static Point pressPoint; + private static bool pressed; + private static InsertionAdorner? adorner; + #endregion + + #region Methods + private static void OnDesignerChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) + { + if (sender is not ListBox list) + { + return; + } + list.PreviewMouseLeftButtonDown -= OnPress; + list.PreviewMouseMove -= OnMove; + list.PreviewMouseLeftButtonUp -= OnRelease; + list.QueryContinueDrag -= OnQueryContinue; + list.DragOver -= OnDragOver; + list.Drop -= OnDrop; + list.DragLeave -= OnDragLeave; + + if (e.NewValue is not DesignerViewModel) + { + return; + } + list.AllowDrop = true; + list.PreviewMouseLeftButtonDown += OnPress; + list.PreviewMouseMove += OnMove; + list.PreviewMouseLeftButtonUp += OnRelease; + list.QueryContinueDrag += OnQueryContinue; + list.DragOver += OnDragOver; + list.Drop += OnDrop; + list.DragLeave += OnDragLeave; + } + + /// 누른 지점 기억 — 실제 드래그는 임계 거리를 넘어야 시작한다(클릭 선택을 방해하지 않도록) + private static void OnPress(object sender, MouseButtonEventArgs e) + { + if (sender is not ListBox list) + { + return; + } + // 눈·자물쇠·셰브론 같은 행 안의 버튼에서 시작한 누름은 드래그가 아니다 + if (e.OriginalSource is DependencyObject origin + && FindAncestor(origin) is not null) + { + return; + } + var row = RowDataOf(e.OriginalSource); + // 최상위 컨트롤만 — 컨테이너 자식은 부모 안에서만 의미가 있어 목록 드래그로 다루지 않는다 + dragSource = row is ControlViewModel control && control.Parent is null ? control : null; + activeList = list; + pressPoint = e.GetPosition(list); + pressed = dragSource is not null; + } + + private static void OnMove(object sender, MouseEventArgs e) + { + if (!pressed || dragSource is null || activeList is null || e.LeftButton != MouseButtonState.Pressed) + { + return; + } + var delta = e.GetPosition(activeList) - pressPoint; + if (Math.Abs(delta.X) < DragThreshold && Math.Abs(delta.Y) < DragThreshold) + { + return; + } + pressed = false; + DragDrop.DoDragDrop(activeList, new DataObject(typeof(ControlViewModel), dragSource), DragDropEffects.Move); + ClearAdorner(); + dragSource = null; + } + + private static void OnRelease(object sender, MouseButtonEventArgs e) => pressed = false; + + /// Esc 로 드래그 취소 — 삽입선도 함께 걷는다 + private static void OnQueryContinue(object sender, QueryContinueDragEventArgs e) + { + if (e.EscapePressed) + { + ClearAdorner(); + } + } + + private static void OnDragOver(object sender, DragEventArgs e) + { + if (sender is not ListBox list || !e.Data.GetDataPresent(typeof(ControlViewModel))) + { + return; + } + var (target, above) = ResolveTarget(list, e); + if (target is null) + { + ClearAdorner(); + e.Effects = DragDropEffects.None; + e.Handled = true; + return; + } + e.Effects = DragDropEffects.Move; + e.Handled = true; + ShowAdorner(list, target, above); + } + + private static void OnDragLeave(object sender, DragEventArgs e) => ClearAdorner(); + + private static void OnDrop(object sender, DragEventArgs e) + { + ClearAdorner(); + if (sender is not ListBox list + || GetDesigner(list) is not { } designer + || e.Data.GetData(typeof(ControlViewModel)) is not ControlViewModel source) + { + return; + } + var (targetItem, above) = ResolveTarget(list, e); + if (targetItem?.DataContext is not ControlViewModel target + || ReferenceEquals(target, source) + || target.Parent is not null) + { + return; + } + designer.MoveControlNextTo(source, target, above); + e.Handled = true; + } + + /// 커서 아래의 드롭 대상 행과 위/아래 판정 + private static (ListBoxItem? Item, bool Above) ResolveTarget(ListBox list, DragEventArgs e) + { + var hit = list.InputHitTest(e.GetPosition(list)) as DependencyObject; + var item = hit is null ? null : FindAncestor(hit); + if (item?.DataContext is not ControlViewModel control || control.Parent is not null) + { + return (null, false); + } + var local = e.GetPosition(item); + return (item, local.Y < item.ActualHeight / 2); + } + + private static void ShowAdorner(ListBox list, ListBoxItem item, bool above) + { + var layer = AdornerLayer.GetAdornerLayer(list); + if (layer is null) + { + return; + } + if (adorner is not null && ReferenceEquals(adorner.AdornedElement, item) && adorner.Above == above) + { + return; + } + ClearAdorner(); + adorner = new InsertionAdorner(item, above); + layer.Add(adorner); + } + + private static void ClearAdorner() + { + if (adorner is null) + { + return; + } + AdornerLayer.GetAdornerLayer(adorner.AdornedElement)?.Remove(adorner); + adorner = null; + } + + private static object? RowDataOf(object? source) + => source is DependencyObject node ? FindAncestor(node)?.DataContext : null; + + private static T? FindAncestor(DependencyObject? node) where T : DependencyObject + { + while (node is not null) + { + if (node is T match) + { + return match; + } + node = node is Visual or System.Windows.Media.Media3D.Visual3D + ? VisualTreeHelper.GetParent(node) + : LogicalTreeHelper.GetParent(node); + } + return null; + } + #endregion + + #region Types + /// + /// 드롭 위치 삽입선 — 대상 행의 위 또는 아래 모서리에 2px 선을 그린다. + /// + /// [200] 은 대상 행의 BorderThickness 를 0→2 로 올려 표시하는데, 그러면 행 내용이 2px 밀려 + /// 드래그 중 목록이 들썩인다. 어도너는 레이아웃 바깥에 그려져 그런 흔들림이 없다. + /// + private sealed class InsertionAdorner : Adorner + { + private static readonly Pen Line = CreatePen(); + + public bool Above { get; } + + public InsertionAdorner(UIElement adorned, bool above) : base(adorned) + { + Above = above; + IsHitTestVisible = false; + } + + protected override void OnRender(DrawingContext drawingContext) + { + var width = ((FrameworkElement)AdornedElement).ActualWidth; + var height = ((FrameworkElement)AdornedElement).ActualHeight; + var y = Above ? 1 : height - 1; + drawingContext.DrawLine(Line, new Point(0, y), new Point(width, y)); + } + + private static Pen CreatePen() + { + // 어도너는 리소스 조회 컨텍스트 밖이라 DynamicResource 가 닿지 않는다. + // 브랜드색은 양 테마 공용값(B.Accent)이라 여기서만 상수로 둔다. + var brush = new SolidColorBrush(Color.FromRgb(0x00, 0x99, 0xFF)); + brush.Freeze(); + var pen = new Pen(brush, 2); + pen.Freeze(); + return pen; + } + } + #endregion +} diff --git a/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs b/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs index b57e27b..7e47f8e 100644 --- a/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs +++ b/src/SheetMe.Designer/Controls/LayerRowTemplateSelector.cs @@ -5,7 +5,7 @@ using SheetMe.Designer.ViewModels; namespace SheetMe.Designer.Controls; /// -/// 레이어 목록 행 템플릿 선택 — 그룹 폴더 행과 컨트롤 행을 가른다. +/// 레이어 목록 행 템플릿 선택 — 페이지 헤더 / 그룹 폴더 / 컨트롤 / '페이지 추가' 네 종류를 가른다. /// /// DataTemplate DataType 암시 매칭을 쓸 수 없다: WPF 는 정확한 런타임 형으로만 찾는데 /// 컨트롤 행의 실제 형은 LabelViewModel·TextBoxViewModel 처럼 제각각이고 @@ -13,12 +13,23 @@ namespace SheetMe.Designer.Controls; /// public sealed class LayerRowTemplateSelector : DataTemplateSelector { + /// 페이지 헤더 행 + public DataTemplate? PageTemplate { get; set; } + /// 그룹 폴더 행 public DataTemplate? GroupTemplate { get; set; } /// 컨트롤 행 public DataTemplate? ControlTemplate { get; set; } - public override DataTemplate? SelectTemplate(object? item, DependencyObject container) - => item is LayerGroupViewModel ? GroupTemplate : ControlTemplate; + /// 맨 아래 '페이지 추가' 행 + public DataTemplate? AddPageTemplate { get; set; } + + public override DataTemplate? SelectTemplate(object? item, DependencyObject container) => item switch + { + LayerPageViewModel => PageTemplate, + LayerGroupViewModel => GroupTemplate, + LayerAddPageViewModel => AddPageTemplate, + _ => ControlTemplate, + }; } diff --git a/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs b/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs index f0c3073..e162400 100644 --- a/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs +++ b/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs @@ -93,6 +93,35 @@ public sealed class TypeToIconConverter : IValueConverter => throw new NotSupportedException(); } +/// +/// "아이콘명|크기|브러시키" 한 문자열 → Lucide 아이콘 비주얼. +/// +/// 색이 행 상태에 따라 달라져야 할 때 쓴다. 보통은 색을 ConverterParameter 에 적지만 +/// ConverterParameter 는 바인딩할 수 없어서, 색이 변하는 순간 아이콘이 이전 색에 굳어 버린다 +/// (선택 밴드 위에서 파란 아이콘이 사라지는 식). 그래서 사양 전체를 값 쪽으로 옮겼다. +/// +public sealed class IconSpecConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string spec) + { + return null; + } + var first = spec.IndexOf('|'); + if (first < 0) + { + return LucideIcons.Icon(spec, 14, "B.Muted"); + } + var name = spec[..first]; + var (size, brushKey) = IconParam.Parse(spec[(first + 1)..], 14); + return LucideIcons.Icon(name, size, brushKey); + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + /// 팔레트 타입명 → 카테고리명 컨버터(CollectionViewSource 그룹핑용) public sealed class TypeToCategoryConverter : IValueConverter { diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs index 2b39150..e4ab742 100644 --- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs +++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs @@ -128,9 +128,68 @@ public static class DialogShots ("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)), ("13-layer-type-filter", () => new Views.LayerTypeFilterDialog( designer.LayerTypeCandidates(), new[] { "Label" })), + // 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다. + // 강조는 IsSelected 가 아니라 Highlight 3상태가 그리므로 눈으로 확인할 경로가 이것뿐이다. + ("14-layer-plain", () => HostLayerPanel(designer, LayerShotMode.None)), + ("15-layer-selected", () => HostLayerPanel(designer, LayerShotMode.Single)), + ("16-layer-group", () => HostLayerPanel(designer, LayerShotMode.Group)), }; } + /// 레이어 패널 스냅숏의 선택 상태 + private enum LayerShotMode + { + /// 선택 없음 + None, + + /// 컨트롤 하나 선택 — 파란 밴드 하나 + Single, + + /// 그룹 전체 선택 — 폴더 파랑 + 멤버 서브트리, 이어진 블록 + Group, + } + + /// + /// 레이어 패널을 실제 패널 폭(255px)의 창에 담는다. + /// MainViewModel 을 통째로 만들면 DB 조회가 딸려 오므로, 패널이 읽는 CurrentDesigner 하나만 + /// 들고 있는 최소 DataContext 를 세워 준다. + /// + private static Window HostLayerPanel(DesignerViewModel designer, LayerShotMode mode) + { + designer.Selection.Clear(); + designer.SetLayerTypeFilter(Array.Empty()); + designer.LayerFilter = string.Empty; + + var page = designer.Pages[0]; + if (mode != LayerShotMode.None && page.Controls.Count >= 3) + { + designer.Selection.Set(new[] { page.Controls[^1], page.Controls[^2] }); + designer.GroupSelection(); + if (mode == LayerShotMode.Single) + { + designer.Selection.SetSingle(page.Controls[0]); + } + } + designer.RebuildLayerRows(); + + return new Window + { + Title = $"레이어 — {mode}", + Width = 255, + Height = 620, + Content = new Views.LayerPanelView { DataContext = new LayerShotContext(designer) }, + Background = Application.Current.TryFindResource("B.Panel") as Brush, + }; + } + + /// 패널이 바인딩하는 CurrentDesigner 만 노출하는 스냅숏 전용 DataContext + private sealed class LayerShotContext + { + public LayerShotContext(DesignerViewModel designer) => CurrentDesigner = designer; + + public DesignerViewModel CurrentDesigner { get; } + } + /// 인스펙터를 실제 패널 폭(300px)의 창에 담는다 — 컨트롤 하나를 선택한 상태로 private static Window HostInspector(DesignerViewModel designer, string controlId, InspectorTab tab = InspectorTab.Design) diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 492f143..309fc11 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -279,12 +279,29 @@ public static class EditSmoke groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"), $"실제 {groupRow?.Members.Count} / {groupRow?.Name}"); + // 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다 + Check("레이어: 첫 행은 페이지 헤더", lay.LayerRows.FirstOrDefault() is LayerPageViewModel); + Check("레이어: 마지막 행은 페이지 추가", lay.LayerRows.LastOrDefault() is LayerAddPageViewModel); + Check("레이어: 페이지 헤더는 선택 대상 아님", + lay.LayerRows.OfType().Where(r => r is LayerPageViewModel or LayerAddPageViewModel) + .All(r => !r.IsSelectableRow)); + // 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스) var folderIndex = lay.LayerRows.IndexOf(groupRow!); Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐", folderIndex >= 0 && lay.LayerRows.Skip(folderIndex + 1).Take(2) - .OfType().Count(c => c.LayerIndent == 20) == 2); + .OfType().Count(c => c.LayerIndent == 32) == 2); + Check("레이어: 페이지 직속 컨트롤 들여쓰기 12", + lay.LayerRows.OfType() + .Where(c => c.Model.GroupId is null && c.Parent is null) + .All(c => c.LayerIndent == 12)); + + // 목록 맨 위 = z-order 최상위 (모델 리스트 index 0) + var topControl = lay.LayerRows.OfType().First(); + Check("레이어: 목록 맨 위가 z-order 최상위", + ReferenceEquals(lay.Document.Pages[0].Controls[0], topControl.Model), + $"실제 맨 위 {topControl.Id} / 모델 첫째 {lay.Document.Pages[0].Controls[0].Id}"); // 접으면 멤버 행이 빠지고, 펴면 되돌아온다 var expandedCount = lay.LayerRows.Count; @@ -371,6 +388,87 @@ public static class EditSmoke lay.GroupSelection(); Check("레이어: 그룹은 XML 에 나가지 않음", !serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase)); + + // 강조 3상태 — 그룹 전체 선택이면 폴더=Blue, 멤버=Navy 이고 연속 블록으로 이어진다 + groupRow = lay.LayerRows.OfType().First(); + lay.Selection.Set(groupRow.Members.ToList(), groupRow.Members[0]); + lay.UpdateLayerHighlight(); + Check("레이어: 그룹 전원 선택 → 폴더 Blue", groupRow.Highlight == LayerHighlight.Blue, + $"실제 {groupRow.Highlight}"); + Check("레이어: 그룹 전원 선택 → 멤버 Navy", + groupRow.Members.All(m => m.Highlight == LayerHighlight.Navy)); + Check("레이어: 폴더와 멤버가 이어진 블록", + groupRow.MergeBottom && groupRow.Members[0].MergeTop); + + lay.Selection.SetSingle(groupRow.Members[0]); + lay.UpdateLayerHighlight(); + Check("레이어: 멤버 하나만 선택 → 그 행만 Blue, 폴더 무강조", + groupRow.Members[0].Highlight == LayerHighlight.Blue + && groupRow.Highlight == LayerHighlight.None); + + // 강조 계산이 페이지 헤더에서 끊긴다(블록이 페이지를 넘지 않는다) + Check("레이어: 페이지 헤더는 항상 무강조", + lay.LayerRows.OfType().All(p => p.Highlight == LayerHighlight.None)); + + // 페이지 복제 — 컨트롤 이름 유일성과 그룹 분리가 지켜져야 한다 + var beforePages = lay.Pages.Count; + var beforeIds = lay.Pages[0].Controls.Select(c => c.Id).ToHashSet(StringComparer.Ordinal); + var copied = lay.DuplicatePage(lay.Pages[0]); + Check("레이어: 페이지 복제 — 장수 +1", lay.Pages.Count == beforePages + 1 && copied is not null); + if (copied is not null) + { + Check("레이어: 페이지 복제 — 컨트롤 수 동일", + copied.Controls.Count == lay.Pages[0].Controls.Count); + Check("레이어: 페이지 복제 — 이름이 전부 새로 발급됨", + copied.Controls.All(c => !beforeIds.Contains(c.Id))); + Check("레이어: 페이지 복제 — 그룹이 원본과 분리됨", + copied.Controls.Where(c => c.Model.GroupId is not null) + .All(c => lay.Pages[0].Controls.All(o => o.Model.GroupId != c.Model.GroupId))); + } + Check("레이어: 전 페이지가 아웃라인에 나옴", + lay.LayerRows.OfType().Count() == lay.Pages.Count); + + // 페이지 접기 — 그 페이지 컨트롤 행만 빠진다 + var pageHeader = lay.LayerRows.OfType().First(); + var beforeCollapse = lay.LayerRows.Count; + pageHeader.IsExpanded = false; + lay.RebuildLayerRows(); + Check("레이어: 페이지 접기 — 행 감소", lay.LayerRows.Count < beforeCollapse); + Check("레이어: 페이지 접어도 헤더는 남음", + lay.LayerRows.OfType().Count() == lay.Pages.Count); + lay.LayerRows.OfType().First().IsExpanded = true; + lay.RebuildLayerRows(); + Check("레이어: 페이지 펴기 — 행 복귀", lay.LayerRows.Count == beforeCollapse); + + // 드래그 재정렬 — 목록에서 아래 행을 맨 위 행 '위'로 옮기면 z-order 최상위가 된다 + var order = lay.LayerRows.OfType() + .Where(c => c.Parent is null && ReferenceEquals(lay.PageOf(c), lay.Pages[0])).ToList(); + if (order.Count >= 2) + { + var mover = order[^1]; + var anchor = order[0]; + lay.MoveControlNextTo(mover, anchor, above: true); + Check("레이어: 드래그로 맨 위에 놓으면 z-order 최상위", + ReferenceEquals(lay.Pages[0].Model.Controls[0], mover.Model), + $"실제 {lay.Pages[0].Model.Controls[0].Id} 기대 {mover.Id}"); + Check("레이어: 재정렬이 되돌리기 스텝", lay.Undo.CanUndo); + } + + // 눈·자물쇠도 되돌릴 수 있어야 한다 + var flagTarget = lay.LayerRows.OfType().First(); + lay.Undo.Snapshot(); + var depthBefore = lay.Undo.CanUndo; + flagTarget.IsLockedFlag = true; + lay.Undo.Undo(); + Check("레이어: 잠금 토글이 Undo 로 원복", + lay.Pages.SelectMany(p => p.Controls).All(c => !c.Model.Locked) && depthBefore); + + // 데이터 배선 배지 + var badgeTarget = lay.Pages[0].Controls[0]; + Check("레이어: 배선 없는 컨트롤은 배지 없음", !badgeTarget.HasDataBinding); + badgeTarget.Model.Props.SetText("DataTableField", "DT.Item(\"ChtNum\")"); + Check("레이어: DataTableField 있으면 배지", badgeTarget.HasDataBinding); + badgeTarget.Model.Props.SetText("DataTableField", string.Empty); } // 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유) diff --git a/src/SheetMe.Designer/Themes/DesignerTheme.xaml b/src/SheetMe.Designer/Themes/DesignerTheme.xaml index bff02db..c1e8f88 100644 --- a/src/SheetMe.Designer/Themes/DesignerTheme.xaml +++ b/src/SheetMe.Designer/Themes/DesignerTheme.xaml @@ -682,6 +682,78 @@ + + + + + + + + + diff --git a/src/SheetMe.Designer/Themes/Tokens.Light.xaml b/src/SheetMe.Designer/Themes/Tokens.Light.xaml index dc58890..ca5d191 100644 --- a/src/SheetMe.Designer/Themes/Tokens.Light.xaml +++ b/src/SheetMe.Designer/Themes/Tokens.Light.xaml @@ -29,6 +29,18 @@ + + + + + diff --git a/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs index c098725..ad285e2 100644 --- a/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs @@ -10,13 +10,16 @@ namespace SheetMe.Designer.ViewModels.Controls; /// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다. /// 좌표계: WinForms px = WPF DIP 1:1. /// -public abstract class ControlViewModel : ViewModelBase +public abstract class ControlViewModel : ViewModelBase, ILayerRow { #region Member Fields private bool isSelected; private bool isHovered; private double layerIndent; private bool isLayerExpanded = true; + private LayerHighlight layerHighlight; + private bool layerMergeTop; + private bool layerMergeBottom; #endregion #region Properties @@ -107,15 +110,27 @@ public abstract class ControlViewModel : ViewModelBase /// 상속 기점 부모 글자색(재해석용 보관) public Brush ParentForeground { get; private set; } = Brushes.Black; + /// + /// 잠금·숨김 토글 직전에 되돌리기 지점을 남기는 콜백 — 평탄화 때 디자이너가 배선한다. + /// (VM 은 UndoService 를 모르고, 알게 하면 모델 투영이라는 역할이 흐려진다) + /// + public Action? LayerFlagChanging { get; set; } + /// 잠금 플래그(레이어 패널 토글) public bool IsLockedFlag { get => Model.Locked; set { + if (Model.Locked == value) + { + return; + } + LayerFlagChanging?.Invoke(); Model.Locked = value; OnPropertyChanged(nameof(IsLockedFlag)); OnPropertyChanged(nameof(LockIcon)); + OnPropertyChanged(nameof(LockIconSpec)); } } @@ -125,9 +140,15 @@ public abstract class ControlViewModel : ViewModelBase get => Model.Hidden; set { + if (Model.Hidden == value) + { + return; + } + LayerFlagChanging?.Invoke(); Model.Hidden = value; OnPropertyChanged(nameof(IsHiddenFlag)); OnPropertyChanged(nameof(HiddenIcon)); + OnPropertyChanged(nameof(HiddenIconSpec)); OnPropertyChanged(nameof(DesignOpacity)); } } @@ -175,6 +196,21 @@ public abstract class ControlViewModel : ViewModelBase /// 레이어 숨김 토글 아이콘명 public string HiddenIcon => Model.Hidden ? "eye-off" : "eye"; + /// + /// 강조 밴드 위 아이콘 색 — 밴드는 짙은 파랑 채움이라 보조색(B.Muted)을 그대로 두면 + /// 눈·자물쇠가 1.6:1 로 묻힌다. 선택한 행에서 잠금 상태가 안 보이면 배지의 의미가 없다. + /// + private string RowIconKey => layerHighlight == LayerHighlight.Blue ? "B.OnAccent" : "B.Muted"; + + /// 잠금 토글 아이콘 사양 + public string LockIconSpec => $"{LockIcon}|13|{RowIconKey}"; + + /// 숨김 토글 아이콘 사양 + public string HiddenIconSpec => $"{HiddenIcon}|13|{RowIconKey}"; + + /// 컨테이너 자식 접기 셰브론 사양 + public string LayerChevronSpec => $"{LayerChevronIcon}|12|{RowIconKey}"; + /// /// 레이어 목록 들여쓰기(px) — 그룹 멤버·컨테이너 자식의 계층을 나타낸다. /// 목록이 평탄한 ListBox 라(TreeView 아님) 깊이를 여백으로만 표현한다 — 평탄화 시 대입된다. @@ -197,6 +233,7 @@ public abstract class ControlViewModel : ViewModelBase if (SetProperty(ref isLayerExpanded, value)) { OnPropertyChanged(nameof(LayerChevronIcon)); + OnPropertyChanged(nameof(LayerChevronSpec)); LayerExpandedChanged?.Invoke(); } } @@ -207,6 +244,62 @@ public abstract class ControlViewModel : ViewModelBase /// 펼침 셰브론 아이콘명 public string LayerChevronIcon => isLayerExpanded ? "chevron-down" : "chevron-right"; + + /// + /// 데이터 배선 배지 — 이 컨트롤이 조회/데이터소스와 엮여 있는가. + /// + /// 판정 기준을 새로 만들지 않고 인스펙터의 '데이터' 탭 분류와 같은 세 속성을 쓴다. + /// 목록에서 "값이 어디서 오는 컨트롤인지"를 한눈에 가려내려는 표시이지 편집 대상이 아니다. + /// + public bool HasDataBinding => + !string.IsNullOrWhiteSpace(Prop("DataInterfaceTag")) + || !string.IsNullOrWhiteSpace(Prop("DataTableField")) + || !string.IsNullOrWhiteSpace(Prop("Query")); + + /// + /// 레이어 행 타입 아이콘 사양 — 강조 밴드 위에서는 아이콘 색도 함께 뒤집는다. + /// 색을 ConverterParameter 에 두면 바인딩이 안 돼 선택 순간 아이콘이 밴드에 묻힌다. + /// + public string LayerIconSpec => + $"{SheetMe.Designer.Controls.PaletteIconCatalog.IconOf(Type)}|14|" + layerHighlight switch + { + LayerHighlight.Blue => "B.OnAccent", + LayerHighlight.Navy => "B.AccentText", + _ => "B.Accent", + }; + #endregion + + #region ILayerRow + /// 레이어 행 강조 상태 + public LayerHighlight Highlight => layerHighlight; + + /// 위 행과 이어진 강조 블록인가 + public bool MergeTop => layerMergeTop; + + /// 아래 행과 이어진 강조 블록인가 + public bool MergeBottom => layerMergeBottom; + + /// 컨트롤 행은 언제나 선택 대상 + public bool IsSelectableRow => true; + + /// 강조 상태 적용 — 디자이너가 선택이 바뀔 때마다 전 행을 한 번에 계산해 밀어 넣는다 + public void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom) + { + var highlightChanged = layerHighlight != highlight; + layerHighlight = highlight; + layerMergeTop = mergeTop; + layerMergeBottom = mergeBottom; + OnPropertyChanged(nameof(Highlight)); + OnPropertyChanged(nameof(MergeTop)); + OnPropertyChanged(nameof(MergeBottom)); + if (highlightChanged) + { + OnPropertyChanged(nameof(LayerIconSpec)); + OnPropertyChanged(nameof(LockIconSpec)); + OnPropertyChanged(nameof(HiddenIconSpec)); + OnPropertyChanged(nameof(LayerChevronSpec)); + } + } #endregion #region Constructors diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index 160c736..b6ecb7b 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -76,8 +76,9 @@ public sealed class DesignerViewModel : ViewModelBase private readonly Dictionary? spreadDesigns; // 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님) private readonly HashSet collapsedGroups = new(StringComparer.Ordinal); + private readonly HashSet collapsedPages = new(); private readonly HashSet layerTypeFilter = new(StringComparer.Ordinal); - private System.Collections.Specialized.INotifyCollectionChanged? watchedControls; + private readonly List watchedControls = new(); #endregion #region Properties @@ -151,27 +152,31 @@ public sealed class DesignerViewModel : ViewModelBase { Selection.ActivePage = value; } - WatchLayerSource(value); - RebuildLayerRows(); + // 아웃라인이 전 페이지를 담으므로 목록 자체는 그대로다 — 활성 표시만 갱신한다 + foreach (var header in LayerRows.OfType()) + { + header.NotifyStateChanged(); + } } } } /// - /// 활성 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다. + /// 문서 전 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다. /// (예전에는 page.Controls 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만, - /// 그룹 폴더가 끼면서 목록이 평탄화 결과가 됐다 — 원본이 변할 때 다시 만들어야 한다.) + /// 아웃라인이 평탄화 결과가 된 뒤로는 원본이 변할 때 다시 만들어야 한다.) /// - private void WatchLayerSource(PageViewModel? page) + private void WatchLayerSource() { - if (watchedControls is not null) + foreach (var watched in watchedControls) { - watchedControls.CollectionChanged -= OnLayerSourceChanged; + watched.CollectionChanged -= OnLayerSourceChanged; } - watchedControls = page?.Controls; - if (watchedControls is not null) + watchedControls.Clear(); + foreach (var page in Pages) { - watchedControls.CollectionChanged += OnLayerSourceChanged; + page.Controls.CollectionChanged += OnLayerSourceChanged; + watchedControls.Add(page.Controls); } } @@ -204,13 +209,16 @@ public sealed class DesignerViewModel : ViewModelBase /// public ObservableCollection LayerRows { get; } = new(); - /// 검색·필터 결과가 0행인지 — 빈 목록에 안내를 띄우기 위한 플래그 - public bool IsLayerListEmpty => LayerRows.Count == 0; + /// + /// 컨트롤 행이 하나도 없는지 — 빈 목록에 안내를 띄우기 위한 플래그. + /// 페이지 헤더와 '페이지 추가' 행은 늘 있으므로 행 수가 아니라 컨트롤 행 유무로 판단한다. + /// + public bool IsLayerListEmpty => !LayerRows.OfType().Any(); - /// 레이어 목록이 비어 보이는 이유 + /// 레이어 목록이 비어 보이는 이유 — 걸러진 것인지, 애초에 없는 것인지 public string LayerEmptyText => - SelectedPage is null || SelectedPage.Controls.Count == 0 - ? "이 페이지에는 컨트롤이 없습니다" + Pages.All(p => p.Controls.Count == 0) + ? "아직 컨트롤이 없습니다" : "검색·필터에 맞는 항목이 없습니다"; /// 타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단 @@ -382,7 +390,9 @@ public sealed class DesignerViewModel : ViewModelBase OnPropertyChanged(nameof(WorldHeight)); RefreshOverlay(); Inspector.Rebuild(); - // SelectedPage 대입이 이미 목록을 만들지만, 페이지가 0개면 setter 가 발화하지 않는다 + // 페이지 인스턴스가 통째로 새로 만들어졌다 — 구독과 접힘 상태를 갈아끼우고 목록을 다시 만든다 + collapsedPages.Clear(); + WatchLayerSource(); RebuildLayerRows(); } @@ -395,6 +405,59 @@ public sealed class DesignerViewModel : ViewModelBase SelectedPage = Pages.LastOrDefault(); } + /// + /// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다. + /// + /// 컨트롤 이름(Id)은 문서 안에서 유일해야 한다 — 액션 대상·입력 파라미터가 이름으로 서로를 + /// 가리키고 CalcBox 수식도 이름으로 합산하므로, 같은 이름이 두 페이지에 있으면 런타임이 어느 쪽을 + /// 잡을지 알 수 없다. 그래서 붙여넣기와 같은 경로로 전부 새 이름을 발급한다. + /// 그룹도 복제본끼리 다시 묶는다(원본 그룹에 끼면 한쪽을 클릭할 때 반대편까지 딸려 온다). + /// + public PageViewModel? DuplicatePage(PageViewModel? source) + { + var target = source ?? SelectedPage; + if (target is null) + { + return null; + } + Undo.Snapshot(); + var clone = target.Model.Clone(); + var usedIds = IdGenerator.CollectUsed(document); + var groupRemap = new Dictionary(StringComparer.Ordinal); + foreach (var element in clone.Controls) + { + AssignNewIds(element, usedIds); + RemapGroupId(element, groupRemap); + } + var index = document.Pages.IndexOf(target.Model); + document.Pages.Insert(index < 0 ? document.Pages.Count : index + 1, clone); + RebuildFromDocument(); + var created = Pages.FirstOrDefault(p => ReferenceEquals(p.Model, clone)); + if (created is not null) + { + SelectedPage = created; + } + return created; + } + + /// 페이지 삭제(확인 후) — 최소 1페이지 유지 + public void RemovePage(PageViewModel? page) + { + var target = page ?? SelectedPage; + if (target is null || document.Pages.Count <= 1) + { + return; + } + if (MessageBox.Show($"페이지 {target.Index + 1}을(를) 삭제할까요? (컨트롤 {target.Controls.Count}개 포함)", + "페이지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes) + { + return; + } + Undo.Snapshot(); + document.Pages.Remove(target.Model); + RebuildFromDocument(); + } + /// 선택 페이지 삭제(확인 후) — 최소 1페이지 유지 public void RemoveSelectedPage() { @@ -1165,6 +1228,87 @@ public sealed class DesignerViewModel : ViewModelBase RefreshOverlay(); } + /// + /// 같은 부모 안에서 임의 위치로 z-order 이동 — 레이어 목록 드래그 재정렬의 모델 연산. + /// + /// 은 맨 앞/맨 뒤 두 극단뿐이고 컨테이너 자식은 아예 건너뛴다. + /// 여기서는 최상위든 컨테이너 자식이든 제 부모 컬렉션 안에서 원하는 자리로 옮긴다. + /// 부모가 다른 컨트롤 사이의 이동(재부모화)은 하지 않는다 — 좌표계가 바뀌는 별개의 연산이다. + /// + /// 좌표계 주의: VM 컬렉션은 그리기 순서(마지막이 최상위), 모델은 XML 순서(index 0 이 + /// 최상위)로 서로 역순이다. 두 리스트를 같이 옮기되 index 를 뒤집어 대응시킨다. + /// + /// 옮길 컨트롤 + /// 목표 위치(부모의 VM 컬렉션 = 그리기 순서 기준 index) + /// 실제로 순서가 바뀌었으면 true + public bool MoveWithinParent(ControlViewModel viewModel, int newDrawIndex) + { + var (viewModels, models) = ParentListsOf(viewModel); + if (viewModels is null || models is null) + { + return false; + } + var oldDrawIndex = viewModels.IndexOf(viewModel); + if (oldDrawIndex < 0) + { + return false; + } + var clamped = Math.Clamp(newDrawIndex, 0, viewModels.Count - 1); + if (clamped == oldDrawIndex) + { + return false; + } + + Undo.Snapshot(); + viewModels.RemoveAt(oldDrawIndex); + viewModels.Insert(clamped, viewModel); + models.Remove(viewModel.Model); + // 그리기 index → 모델 index (역순). Remove 후 개수가 하나 줄었으므로 Count 를 그대로 쓴다. + models.Insert(models.Count - clamped, viewModel.Model); + RefreshOverlay(); + RebuildLayerRows(); + return true; + } + + /// + /// 레이어 목록 드래그 재정렬 — 의 + /// 위(=true) 또는 아래로 옮긴다. + /// + /// 목록은 z-order 최상위가 맨 위인데 VM 컬렉션은 그리기 순서(마지막이 최상위)라 방향이 반대다. + /// 그래서 "목록에서 위" = "VM 컬렉션에서 뒤"로 뒤집어 계산한다. + /// + public bool MoveControlNextTo(ControlViewModel source, ControlViewModel target, bool above) + { + var (viewModels, _) = ParentListsOf(source); + if (viewModels is null || !viewModels.Contains(target) || ReferenceEquals(source, target)) + { + return false; + } + var from = viewModels.IndexOf(source); + var targetIndex = viewModels.IndexOf(target); + + // 목록 위 = 그리기 순서 뒤. 대상 '위'에 놓으려면 대상보다 뒤(=index 큼)로 가야 한다. + var to = above ? targetIndex + 1 : targetIndex; + // 자기 자신을 뺀 뒤의 자리로 보정 + if (from < to) + { + to--; + } + return MoveWithinParent(source, to); + } + + /// 컨트롤이 속한 부모의 (VM 컬렉션, 모델 컬렉션) — 최상위면 페이지, 아니면 컨테이너 + private (System.Collections.ObjectModel.ObservableCollection? ViewModels, List? Models) + ParentListsOf(ControlViewModel viewModel) + { + if (viewModel.Parent is ContainerViewModel container) + { + return (container.Children, container.Model.Children); + } + var page = PageOf(viewModel); + return page is null ? (null, null) : (page.Controls, page.Model.Controls); + } + /// 방향키 넛지 — 400ms 코얼레스 Undo public void NudgeSelection(double dx, double dy) { @@ -1296,17 +1440,23 @@ public sealed class DesignerViewModel : ViewModelBase ? name : LayerGroupViewModel.FallbackName; - /// 그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다 + /// 그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다(그 경로도 되돌릴 수 있어야 한다) public void RenameGroup(string groupId, string? name) { var trimmed = (name ?? string.Empty).Trim(); - if (trimmed.Length == 0 || trimmed == LayerGroupViewModel.FallbackName) + var current = document.GroupNames.TryGetValue(groupId, out var existing) ? existing : string.Empty; + var next = trimmed == LayerGroupViewModel.FallbackName ? string.Empty : trimmed; + if (string.Equals(current, next, StringComparison.Ordinal)) + { + return; + } + Undo.Snapshot(); + if (next.Length == 0) { document.GroupNames.Remove(groupId); return; } - Undo.Snapshot(); - document.GroupNames[groupId] = trimmed; + document.GroupNames[groupId] = next; } /// 그룹 펼침 상태 — 기본은 펼침 @@ -1397,29 +1547,25 @@ public sealed class DesignerViewModel : ViewModelBase RebuildLayerRows(); } - /// 활성 페이지에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순 + /// 문서에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순 public List<(string Type, int Count)> LayerTypeCandidates() { var counts = new Dictionary(StringComparer.Ordinal); - void Visit(IEnumerable items) + foreach (var control in AllControls()) { - 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); - } - } + counts[control.Type] = counts.TryGetValue(control.Type, out var n) ? n + 1 : 1; } - Visit(SelectedPage?.Controls ?? Enumerable.Empty()); return counts.OrderByDescending(p => p.Value).ThenBy(p => p.Key, StringComparer.Ordinal) .Select(p => (p.Key, p.Value)).ToList(); } /// - /// 레이어 목록 재구성 — 그룹 폴더 + 들여쓴 멤버 + 컨테이너 자식을 한 줄 시퀀스로 평탄화한다. - /// 순서는 페이지의 그리기 순서를 그대로 따른다(캔버스에서 뒤에 있는 것이 목록 위). + /// 레이어 목록 재구성 — 문서의 전 페이지를 한 아웃라인으로 평탄화한다. + /// 페이지 헤더 → (펼쳐져 있으면) 그룹 폴더·컨트롤·컨테이너 자식 → 다음 페이지 → 맨 끝에 '페이지 추가'. + /// + /// 순서는 z-order 최상위가 맨 위다(Figma·[200] 규약). PageViewModel.Controls 는 이미 + /// 그리기 순서(마지막이 최상위)라 역순으로 훑는다 — 캔버스에서 위에 겹쳐 보이는 컨트롤을 + /// 목록에서도 위에서 찾게 된다. /// public void RebuildLayerRows() { @@ -1427,11 +1573,17 @@ public sealed class DesignerViewModel : ViewModelBase IsRebuildingLayers = true; LayerRows.Clear(); - var page = SelectedPage; - if (page is not null) + foreach (var page in Pages) { + var header = new LayerPageViewModel(this, page); + LayerRows.Add(header); + if (!header.IsExpanded) + { + continue; + } + var rendered = new HashSet(StringComparer.Ordinal); - foreach (var control in page.Controls) + foreach (var control in page.Controls.Reverse()) { if (control.Model.GroupId is { } groupId) { @@ -1439,7 +1591,7 @@ public sealed class DesignerViewModel : ViewModelBase { continue; } - var members = page.Controls.Where(c => c.Model.GroupId == groupId).ToList(); + var members = page.Controls.Reverse().Where(c => c.Model.GroupId == groupId).ToList(); var shown = members.Where(PassesLayerFilter).ToList(); if (shown.Count == 0) { @@ -1451,24 +1603,93 @@ public sealed class DesignerViewModel : ViewModelBase { foreach (var member in shown) { - AddLayerRow(member, 20); + AddLayerRow(member, GroupMemberIndent); } } continue; } if (PassesLayerFilter(control)) { - AddLayerRow(control, 0); + AddLayerRow(control, PageChildIndent); } } } + LayerRows.Add(new LayerAddPageViewModel()); IsRebuildingLayers = false; + UpdateLayerHighlight(); OnPropertyChanged(nameof(IsLayerListEmpty)); OnPropertyChanged(nameof(LayerEmptyText)); LayerRowsRebuilt?.Invoke(); } + /// 페이지 직속 컨트롤·그룹 폴더의 들여쓰기 — 페이지 노드 아래라 항상 한 칸 들어간다 + private const double PageChildIndent = 12; + + /// 그룹 멤버 들여쓰기 — 페이지 직속보다 한 단계 더 + private const double GroupMemberIndent = 32; + + /// 컨테이너(패널/그룹박스) 자식의 추가 들여쓰기 + private const double ContainerChildIndent = 16; + + /// + /// 선택 강조 3상태 + 연속 블록 병합 계산 — 목록을 다시 만들지 않고 색만 갱신한다. + /// + /// 선택이 바뀔 때마다 평탄화를 다시 돌리면 1,067행 서식에서 클릭 한 번에 목록 전체가 재생성된다. + /// 그래서 강조는 별도 경로로 분리했다. + /// + /// 그룹 집계는 전 페이지를 대상으로 한다 — 그룹 식별자는 페이지를 넘나들 수 있고, + /// 폴더가 파랑이 되려면 "그 그룹의 모든 멤버"가 선택돼 있어야 한다. + /// + public void UpdateLayerHighlight() + { + var total = new Dictionary(StringComparer.Ordinal); + var selected = new Dictionary(StringComparer.Ordinal); + foreach (var control in AllControls()) + { + if (control.Model.GroupId is not { } id) + { + continue; + } + total[id] = total.TryGetValue(id, out var t) ? t + 1 : 1; + if (control.IsSelected) + { + selected[id] = selected.TryGetValue(id, out var s) ? s + 1 : 1; + } + } + + bool GroupFullySelected(string id) + => total.TryGetValue(id, out var t) && t > 0 + && selected.TryGetValue(id, out var s) && s == t; + + var states = new LayerHighlight[LayerRows.Count]; + for (var i = 0; i < LayerRows.Count; i++) + { + states[i] = LayerRows[i] switch + { + // 그룹 전체가 선택되면 파랑은 폴더가 가져가고 멤버는 서브트리 배경으로 물러난다 + ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy, + ControlViewModel c when c.IsSelected => LayerHighlight.Blue, + LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue, + _ => LayerHighlight.None, + }; + } + + for (var i = 0; i < LayerRows.Count; i++) + { + if (LayerRows[i] is not ILayerRow row) + { + continue; + } + // 병합 판정은 '색'이 아니라 '강조냐 아니냐'다 — 그래야 파랑 폴더와 네이비 멤버가 + // 색만 다른 하나의 이어진 블록으로 보인다 + var top = i > 0 && states[i] != LayerHighlight.None && states[i - 1] != LayerHighlight.None; + var bottom = i < LayerRows.Count - 1 + && states[i] != LayerHighlight.None && states[i + 1] != LayerHighlight.None; + row.ApplyHighlight(states[i], top, bottom); + } + } + /// /// 목록 재구성 중 — 이때 ListBox 가 쏟아내는 선택 해제는 사용자의 조작이 아니다. /// (LayerRows.Clear() 가 SelectedItems 를 비우면서 SelectionChanged 를 발화한다) @@ -1483,14 +1704,18 @@ public sealed class DesignerViewModel : ViewModelBase { viewModel.LayerIndent = indent; viewModel.LayerExpandedChanged ??= RequestLayerRebuild; + // 눈·자물쇠도 편집이다 — 실수로 서식 절반을 잠갔을 때 Ctrl+Z 로 돌아올 수 있어야 한다. + // 연속 토글은 400ms 안에서 한 스텝으로 묶는다(한 줄씩 잠그다 보면 Undo 스택이 금방 찬다). + viewModel.LayerFlagChanging ??= () => Undo.SnapshotForNudge(); LayerRows.Add(viewModel); if (viewModel is not ContainerViewModel container || !viewModel.IsLayerExpanded) { return; } - foreach (var child in container.Children.Where(PassesLayerFilter)) + // 자식도 z-order 최상위가 위로 오게 역순 + foreach (var child in container.Children.Reverse().Where(PassesLayerFilter)) { - AddLayerRow(child, indent + 16); + AddLayerRow(child, indent + ContainerChildIndent); } } @@ -1518,16 +1743,17 @@ public sealed class DesignerViewModel : ViewModelBase } } - /// 선택 집합이 바뀌었을 때 — 그룹 폴더의 눈·자물쇠 표시를 다시 계산 + /// 선택 집합이 바뀌었을 때 — 강조 3상태와 그룹 폴더의 눈·자물쇠 표시를 다시 계산 private void LayerSelectionChanged() { + UpdateLayerHighlight(); foreach (var group in LayerRows.OfType()) { group.NotifyFlagsChanged(); } } - /// 레이어 행(컨트롤 또는 그룹 폴더) → 선택 대상 컨트롤 목록 + /// 레이어 행 → 선택 대상 컨트롤 목록. 페이지 행·추가 행은 선택 대상이 아니라 빈 목록 public static IEnumerable ControlsOfLayerRow(object? row) => row switch { ControlViewModel control => new[] { control }, @@ -1535,6 +1761,36 @@ public sealed class DesignerViewModel : ViewModelBase _ => Enumerable.Empty(), }; + /// 페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 페이지 인스턴스로 잡는다 + /// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다) + public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page); + + /// 페이지 펼침/접기 + public void SetPageExpanded(PageViewModel page, bool expanded) + { + if (expanded ? collapsedPages.Remove(page) : collapsedPages.Add(page)) + { + RequestLayerRebuild(); + } + } + + /// 페이지 헤더 행 클릭 — 활성 페이지로 삼고 캔버스를 그 페이지로 옮긴다 + public void ActivatePage(PageViewModel page) + { + if (!ReferenceEquals(SelectedPage, page)) + { + SelectedPage = page; + } + foreach (var header in LayerRows.OfType()) + { + header.NotifyStateChanged(); + } + ScrollToPageRequested?.Invoke(page); + } + + /// 캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다 + public event Action? ScrollToPageRequested; + /// 잘라내기 — 복사 후 삭제 public void CutSelection() { diff --git a/src/SheetMe.Designer/ViewModels/LayerGroupViewModel.cs b/src/SheetMe.Designer/ViewModels/LayerGroupViewModel.cs index f939219..9b237b1 100644 --- a/src/SheetMe.Designer/ViewModels/LayerGroupViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/LayerGroupViewModel.cs @@ -10,12 +10,15 @@ namespace SheetMe.Designer.ViewModels; /// 새로 만들어진다 — 펼침 상태·이름은 여기에 두지 않고 각각 /// 의 접힌 그룹 집합과 FormDocument.GroupNames 가 소유한다. /// -public sealed class LayerGroupViewModel : ViewModelBase +public sealed class LayerGroupViewModel : ViewModelBase, ILayerRow { #region Member Fields private const string DefaultName = "그룹"; private readonly DesignerViewModel designer; private bool isEditing; + private LayerHighlight highlight; + private bool mergeTop; + private bool mergeBottom; #endregion #region Properties @@ -51,6 +54,7 @@ public sealed class LayerGroupViewModel : ViewModelBase designer.SetGroupExpanded(GroupId, value); OnPropertyChanged(nameof(IsExpanded)); OnPropertyChanged(nameof(ChevronIcon)); + OnPropertyChanged(nameof(ChevronSpec)); } } @@ -94,6 +98,18 @@ public sealed class LayerGroupViewModel : ViewModelBase /// 숨김 아이콘명 — 하나라도 숨겨져 있으면 숨긴 모양 public string HiddenIcon => Members.Any(m => m.IsHiddenFlag) ? "eye-off" : "eye"; + /// 강조 밴드 위 아이콘 색 — 짙은 파랑 채움 위에서 보조색은 묻힌다 + private string RowIconKey => highlight == LayerHighlight.Blue ? "B.OnAccent" : "B.Muted"; + + /// 그룹 잠금 아이콘 사양 + public string LockIconSpec => $"{LockIcon}|13|{RowIconKey}"; + + /// 그룹 숨김 아이콘 사양 + public string HiddenIconSpec => $"{HiddenIcon}|13|{RowIconKey}"; + + /// 그룹 접기 셰브론 사양 + public string ChevronSpec => $"{ChevronIcon}|12|{RowIconKey}"; + /// 이름 인라인 편집 중 — 더블클릭으로 켠다 public bool IsEditing { @@ -111,6 +127,47 @@ public sealed class LayerGroupViewModel : ViewModelBase } #endregion + #region ILayerRow + /// 폴더 행 강조 — 멤버가 전부 선택됐을 때만 파랑 + public LayerHighlight Highlight => highlight; + + /// 위 행과 이어진 강조 블록인가 + public bool MergeTop => mergeTop; + + /// 아래 행과 이어진 강조 블록인가 — 폴더 바로 아래가 멤버라 보통 참이다 + public bool MergeBottom => mergeBottom; + + /// 폴더 행도 클릭으로 그룹 전체를 고를 수 있다 + public bool IsSelectableRow => true; + + /// 강조 상태 적용 + public void ApplyHighlight(LayerHighlight value, bool top, bool bottom) + { + var changed = highlight != value; + highlight = value; + mergeTop = top; + mergeBottom = bottom; + OnPropertyChanged(nameof(Highlight)); + OnPropertyChanged(nameof(MergeTop)); + OnPropertyChanged(nameof(MergeBottom)); + if (changed) + { + OnPropertyChanged(nameof(FolderIconSpec)); + OnPropertyChanged(nameof(LockIconSpec)); + OnPropertyChanged(nameof(HiddenIconSpec)); + OnPropertyChanged(nameof(ChevronSpec)); + } + } + + /// 폴더 아이콘 사양 — 강조 밴드 위에서는 흰색으로 뒤집는다 + public string FolderIconSpec => "folder|14|" + highlight switch + { + LayerHighlight.Blue => "B.OnAccent", + LayerHighlight.Navy => "B.AccentText", + _ => "B.Accent", + }; + #endregion + #region Methods /// 멤버 플래그가 밖에서 바뀌었을 때 아이콘·토글 상태 재계산 public void NotifyFlagsChanged() @@ -119,6 +176,8 @@ public sealed class LayerGroupViewModel : ViewModelBase OnPropertyChanged(nameof(IsLockedFlag)); OnPropertyChanged(nameof(HiddenIcon)); OnPropertyChanged(nameof(LockIcon)); + OnPropertyChanged(nameof(HiddenIconSpec)); + OnPropertyChanged(nameof(LockIconSpec)); } /// 이름이 없을 때의 기본 표시 diff --git a/src/SheetMe.Designer/ViewModels/LayerRows.cs b/src/SheetMe.Designer/ViewModels/LayerRows.cs new file mode 100644 index 0000000..9a92160 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/LayerRows.cs @@ -0,0 +1,186 @@ +using SheetMe.Designer.ViewModels.Controls; + +namespace SheetMe.Designer.ViewModels; + +/// +/// 레이어 행의 강조 상태 — [200]SheetMe 의 3상태(OutlineHL)를 그대로 옮긴 것. +/// +public enum LayerHighlight +{ + /// 강조 없음 + None, + + /// 선택된 행 — 채워진 강조 밴드 + Blue, + + /// 그룹이 통째로 선택됐을 때의 멤버 행 — 폴더가 파랑을 가져가고 멤버는 짙은 서브트리 배경 + Navy, +} + +/// +/// 레이어 목록 행 공통 — 강조 상태와 연속 병합 정보를 노출한다. +/// +/// 선택 표시를 ListBoxItem.IsSelected 가 아니라 이 값으로 그리는 이유: +/// 그룹 폴더(파랑)와 그 멤버(네이비)는 색이 다른데도 하나의 이어진 블록이어야 하고, +/// 페이지 행처럼 선택 자체가 불가능하면서 배경은 따로 가지는 행도 있다. +/// IsSelected 로는 이 셋을 한 축으로 표현할 수 없다. +/// +public interface ILayerRow +{ + /// 이 행의 강조 상태 + LayerHighlight Highlight { get; } + + /// 바로 위 행도 강조 상태다 — 위쪽 모서리를 각지게 해 블록을 잇는다 + bool MergeTop { get; } + + /// 바로 아래 행도 강조 상태다 — 아래쪽 모서리를 각지게 하고 1px 겹친다 + bool MergeBottom { get; } + + /// 목록에서 선택 대상인가 — 페이지 행·추가 행은 false + bool IsSelectableRow { get; } + + /// 강조/병합 상태 적용(디자이너가 일괄 계산해 밀어 넣는다) + void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom); +} + +/// +/// 레이어 목록의 페이지 헤더 행 — [200] PageHeaderRow 대응. +/// +/// 페이지를 목록 바깥의 별도 섹션이 아니라 아웃라인의 최상위 노드로 둔다. 부수 효과가 하나 있는데 +/// 의도한 것이다: 페이지 행은 항상 이라 연속 강조 블록이 +/// 페이지 경계에서 저절로 끊긴다([200] 이 Separator 노드로 하던 일). +/// +public sealed class LayerPageViewModel : ViewModelBase, ILayerRow +{ + #region Member Fields + private readonly DesignerViewModel designer; + private LayerHighlight highlight; + private bool mergeTop; + private bool mergeBottom; + #endregion + + #region Properties + /// 대상 페이지 + public PageViewModel Page { get; } + + /// 표시 제목 — "페이지 1" + public string Title => $"페이지 {Page.Index + 1}"; + + /// 컨트롤 수 표시 — 컨테이너 자식까지 센다 + public string CountText => $"({CountControls(Page.Controls)})"; + + /// 활성 페이지인가 — 배경 강조와 아이콘 색이 갈린다 + public bool IsCurrent => ReferenceEquals(designer.SelectedPage, Page); + + /// 컨트롤이 하나도 없는가 — "(비어 있음)" 둘째 줄 + public bool IsEmpty => Page.Controls.Count == 0; + + /// 삭제 버튼을 그릴지 — 마지막 한 장은 지울 수 없다 + public bool CanDelete => designer.Pages.Count > 1; + + /// 펼침 상태 + public bool IsExpanded + { + get => designer.IsPageExpanded(Page); + set + { + designer.SetPageExpanded(Page, value); + OnPropertyChanged(nameof(IsExpanded)); + OnPropertyChanged(nameof(ChevronIcon)); + OnPropertyChanged(nameof(ChevronSpec)); + } + } + + /// 펼침 셰브론 아이콘명 + public string ChevronIcon => IsExpanded ? "chevron-down" : "chevron-right"; + + /// 펼침 셰브론 사양 — 페이지 행은 강조되지 않으므로 색이 고정이다 + public string ChevronSpec => $"{ChevronIcon}|12|B.Muted"; + + /// 페이지 아이콘 사양 — 활성 페이지만 강조색 + public string PageIconSpec => IsCurrent ? "file-text|13|B.Accent" : "file-text|13|B.Muted"; + + /// 첫 페이지인가 — 위 여백을 줄지 판단 + public bool IsFirst => Page.Index == 0; + #endregion + + #region Constructors + public LayerPageViewModel(DesignerViewModel designer, PageViewModel page) + { + this.designer = designer; + Page = page; + } + #endregion + + #region ILayerRow + public LayerHighlight Highlight => highlight; + + public bool MergeTop => mergeTop; + + public bool MergeBottom => mergeBottom; + + public bool IsSelectableRow => false; + + public void ApplyHighlight(LayerHighlight value, bool top, bool bottom) + { + // 페이지 행은 강조 대상이 아니다 — 무엇을 받든 None 으로 눌러 연속 블록을 끊는다 + highlight = LayerHighlight.None; + mergeTop = false; + mergeBottom = false; + OnPropertyChanged(nameof(Highlight)); + OnPropertyChanged(nameof(MergeTop)); + OnPropertyChanged(nameof(MergeBottom)); + } + #endregion + + #region Methods + /// 표시 상태 재계산 통지 — 활성 페이지 전환·컨트롤 증감 후 + public void NotifyStateChanged() + { + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(CountText)); + OnPropertyChanged(nameof(IsCurrent)); + OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(CanDelete)); + OnPropertyChanged(nameof(PageIconSpec)); + OnPropertyChanged(nameof(IsFirst)); + } + + /// 컨테이너 자식까지 포함한 컨트롤 수 + private static int CountControls(IEnumerable items) + { + var total = 0; + foreach (var item in items) + { + total++; + if (item is ContainerViewModel container) + { + total += CountControls(container.Children); + } + } + return total; + } + #endregion +} + +/// +/// 목록 맨 아래의 '+ 페이지 추가' 행 — [200] AddPageRow 대응. +/// 페이지 섹션을 없앤 자리에서 페이지 추가 진입점을 대신한다. +/// +public sealed class LayerAddPageViewModel : ILayerRow +{ + #region ILayerRow + public LayerHighlight Highlight => LayerHighlight.None; + + public bool MergeTop => false; + + public bool MergeBottom => false; + + public bool IsSelectableRow => false; + + public void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom) + { + // 항상 무강조 — 받을 상태가 없다 + } + #endregion +} diff --git a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs index 15c97e4..d2ee058 100644 --- a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs +++ b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs @@ -41,6 +41,7 @@ public partial class DesignerCanvasView : UserControl { subscribedDesigner.InlineEditRequested -= ShowInlineEditor; subscribedDesigner.ScrollToRequested -= ScrollToControl; + subscribedDesigner.ScrollToPageRequested -= ScrollToPage; } CloseInlineEditor(commit: false); subscribedDesigner = e.NewValue as DesignerViewModel; @@ -48,10 +49,29 @@ public partial class DesignerCanvasView : UserControl { subscribedDesigner.InlineEditRequested += ShowInlineEditor; subscribedDesigner.ScrollToRequested += ScrollToControl; + subscribedDesigner.ScrollToPageRequested += ScrollToPage; subscribedDesigner.ReportViewport(Scroll.ViewportWidth, Scroll.ViewportHeight); } } + /// + /// 페이지 머리가 화면 위쪽에 오도록 스크롤 — 레이어 아웃라인의 페이지 헤더 클릭 경로. + /// 캔버스가 전 페이지를 하나의 월드에 세로로 쌓아 그리므로 페이지 오프셋만 알면 된다. + /// + private void ScrollToPage(PageViewModel page) + { + if (subscribedDesigner is null) + { + return; + } + Dispatcher.BeginInvoke(new Action(() => + { + const double CanvasPad = 24; // DesignerCanvasView.xaml 의 Border Padding + const double HeadRoom = 8; // 페이지 윗선이 뷰포트 경계에 딱 붙지 않도록 + Scroll.ScrollToVerticalOffset(page.OffsetY * subscribedDesigner.Zoom + CanvasPad - HeadRoom); + }), System.Windows.Threading.DispatcherPriority.Loaded); + } + /// /// 컨트롤이 화면 가운데 오도록 스크롤 — 레이어 목록 더블클릭 경로. /// diff --git a/src/SheetMe.Designer/Views/LayerPanelView.xaml b/src/SheetMe.Designer/Views/LayerPanelView.xaml new file mode 100644 index 0000000..d138071 --- /dev/null +++ b/src/SheetMe.Designer/Views/LayerPanelView.xaml @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + +