레이어 패널을 [200]SheetMe 아웃라인으로 교체 — 페이지 노드·선택 밴드·드래그 재정렬
[200] 의 패널은 코드로 Border 를 쌓는 비가상화 렌더고 SheetMe 는 가상화 ListBox 다. 골격은 SheetMe 것을 지키고 겉모습과 조작 표면만 옮겼다 — 통째로 베끼면 1,067 컨트롤 서식에서 검색어 한 글자마다 수천 비주얼을 다시 만들게 되고, [200] 에 대응물이 없는 컨테이너 자식 재귀 표시가 사라진다. 구조 - 페이지를 별도 섹션이 아니라 아웃라인의 최상위 노드로. 접기·복제·삭제·컨트롤 수·현재 강조 - 맨 아래 '+ 페이지 추가' 행 - 목록 순서를 뒤집었다 — 이제 맨 위가 z-order 최상위(캔버스에서 위에 겹쳐 보이는 것이 목록 위) - 들여쓰기 12 / 32 / +16 - 레이어 패널을 LayerPanelView 로 분리(MainView.xaml 이 1,000줄을 넘겼고, 진단 렌더러가 이 패널만 따로 찍어 라이트·다크·선택 상태를 검사할 수 있어야 한다) 선택 표시 - IsSelected 가 아니라 Highlight 3상태(None/Blue/Navy)가 배경을 그린다. 그룹 전원 선택 시 폴더는 파랑, 멤버는 서브트리 배경이 되고 맞닿는 모서리를 각지게 해 하나의 블록으로 이어진다 - 밴드 위에서는 아이콘·보조 텍스트 색도 함께 뒤집는다. 안 그러면 눈·자물쇠가 1.6:1 로 묻혀 선택한 행에서만 잠금 상태가 안 보이는 상태가 된다 - 신설 토큰 3종(B.LayerSel / B.LayerSubtree / B.OnSelMuted) — [200] 원본값은 각각 흰 글자 3.00:1, 다크 밴드 1.03:1, 라이트 밴드 1.14:1 로 이 저장소가 이미 기각한 값이다 기능 - 드래그로 z-order 재정렬(삽입선 어도너). 임의 index 이동 연산 MoveWithinParent 신설 - 페이지 복제 — 컨트롤 이름을 전부 새로 발급하고 그룹도 복제본끼리 다시 묶는다 ([200] 은 GroupId 를 그대로 둬서 복제 그룹이 원본과 gid 를 공유한다) - 페이지 헤더 클릭 → 캔버스가 그 페이지로 이동(ScrollToPage) - 데이터 배선 배지 — 값이 조회·데이터소스에서 오는 컨트롤 표시 - 검색칩에 테두리(라이트에서 B.Input=B.Panel 이라 경계 대비가 1.00:1 이었다) 이식하지 않은 것: 컨트롤 행 인라인 개명. [200] 이 고치는 값은 표시 전용 별칭이지만 SheetMe 의 컨트롤 이름은 외부 참조 키라 목록에서 무심코 고치면 배선이 끊긴다. 더블클릭은 요청대로 캔버스 이동을 유지하고, 그룹 행에서만 이름 편집으로 간다. 동반 수정: 눈·자물쇠 토글에 되돌리기 지점 추가(400ms 코얼레스), 그룹 이름 비우기 경로가 스냅숏 없이 지워지던 것, 숨김 흐리기를 행 전체가 아니라 이름 줄에만. 편집 스모크의 레이어 검사 19 → 42건. 회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 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
a30421f9b5
commit
76584e5004
@@ -10,13 +10,16 @@ namespace SheetMe.Designer.ViewModels.Controls;
|
||||
/// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다.
|
||||
/// 좌표계: WinForms px = WPF DIP 1:1.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>상속 기점 부모 글자색(재해석용 보관)</summary>
|
||||
public Brush ParentForeground { get; private set; } = Brushes.Black;
|
||||
|
||||
/// <summary>
|
||||
/// 잠금·숨김 토글 직전에 되돌리기 지점을 남기는 콜백 — 평탄화 때 디자이너가 배선한다.
|
||||
/// (VM 은 UndoService 를 모르고, 알게 하면 모델 투영이라는 역할이 흐려진다)
|
||||
/// </summary>
|
||||
public Action? LayerFlagChanging { get; set; }
|
||||
|
||||
/// <summary>잠금 플래그(레이어 패널 토글)</summary>
|
||||
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
|
||||
/// <summary>레이어 숨김 토글 아이콘명</summary>
|
||||
public string HiddenIcon => Model.Hidden ? "eye-off" : "eye";
|
||||
|
||||
/// <summary>
|
||||
/// 강조 밴드 위 아이콘 색 — 밴드는 짙은 파랑 채움이라 보조색(B.Muted)을 그대로 두면
|
||||
/// 눈·자물쇠가 1.6:1 로 묻힌다. 선택한 행에서 잠금 상태가 안 보이면 배지의 의미가 없다.
|
||||
/// </summary>
|
||||
private string RowIconKey => layerHighlight == LayerHighlight.Blue ? "B.OnAccent" : "B.Muted";
|
||||
|
||||
/// <summary>잠금 토글 아이콘 사양</summary>
|
||||
public string LockIconSpec => $"{LockIcon}|13|{RowIconKey}";
|
||||
|
||||
/// <summary>숨김 토글 아이콘 사양</summary>
|
||||
public string HiddenIconSpec => $"{HiddenIcon}|13|{RowIconKey}";
|
||||
|
||||
/// <summary>컨테이너 자식 접기 셰브론 사양</summary>
|
||||
public string LayerChevronSpec => $"{LayerChevronIcon}|12|{RowIconKey}";
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 목록 들여쓰기(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
|
||||
|
||||
/// <summary>펼침 셰브론 아이콘명</summary>
|
||||
public string LayerChevronIcon => isLayerExpanded ? "chevron-down" : "chevron-right";
|
||||
|
||||
/// <summary>
|
||||
/// 데이터 배선 배지 — 이 컨트롤이 조회/데이터소스와 엮여 있는가.
|
||||
///
|
||||
/// 판정 기준을 새로 만들지 않고 인스펙터의 '데이터' 탭 분류와 같은 세 속성을 쓴다.
|
||||
/// 목록에서 "값이 어디서 오는 컨트롤인지"를 한눈에 가려내려는 표시이지 편집 대상이 아니다.
|
||||
/// </summary>
|
||||
public bool HasDataBinding =>
|
||||
!string.IsNullOrWhiteSpace(Prop("DataInterfaceTag"))
|
||||
|| !string.IsNullOrWhiteSpace(Prop("DataTableField"))
|
||||
|| !string.IsNullOrWhiteSpace(Prop("Query"));
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 행 타입 아이콘 사양 — 강조 밴드 위에서는 아이콘 색도 함께 뒤집는다.
|
||||
/// 색을 ConverterParameter 에 두면 바인딩이 안 돼 선택 순간 아이콘이 밴드에 묻힌다.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>레이어 행 강조 상태</summary>
|
||||
public LayerHighlight Highlight => layerHighlight;
|
||||
|
||||
/// <summary>위 행과 이어진 강조 블록인가</summary>
|
||||
public bool MergeTop => layerMergeTop;
|
||||
|
||||
/// <summary>아래 행과 이어진 강조 블록인가</summary>
|
||||
public bool MergeBottom => layerMergeBottom;
|
||||
|
||||
/// <summary>컨트롤 행은 언제나 선택 대상</summary>
|
||||
public bool IsSelectableRow => true;
|
||||
|
||||
/// <summary>강조 상태 적용 — 디자이너가 선택이 바뀔 때마다 전 행을 한 번에 계산해 밀어 넣는다</summary>
|
||||
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
|
||||
|
||||
@@ -76,8 +76,9 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns;
|
||||
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
|
||||
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<PageViewModel> collapsedPages = new();
|
||||
private readonly HashSet<string> layerTypeFilter = new(StringComparer.Ordinal);
|
||||
private System.Collections.Specialized.INotifyCollectionChanged? watchedControls;
|
||||
private readonly List<System.Collections.Specialized.INotifyCollectionChanged> 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<LayerPageViewModel>())
|
||||
{
|
||||
header.NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 활성 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
|
||||
/// 문서 전 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
|
||||
/// (예전에는 <c>page.Controls</c> 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만,
|
||||
/// 그룹 폴더가 끼면서 목록이 평탄화 결과가 됐다 — 원본이 변할 때 다시 만들어야 한다.)
|
||||
/// 아웃라인이 평탄화 결과가 된 뒤로는 원본이 변할 때 다시 만들어야 한다.)
|
||||
/// </summary>
|
||||
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
|
||||
/// </summary>
|
||||
public ObservableCollection<object> LayerRows { get; } = new();
|
||||
|
||||
/// <summary>검색·필터 결과가 0행인지 — 빈 목록에 안내를 띄우기 위한 플래그</summary>
|
||||
public bool IsLayerListEmpty => LayerRows.Count == 0;
|
||||
/// <summary>
|
||||
/// 컨트롤 행이 하나도 없는지 — 빈 목록에 안내를 띄우기 위한 플래그.
|
||||
/// 페이지 헤더와 '페이지 추가' 행은 늘 있으므로 행 수가 아니라 컨트롤 행 유무로 판단한다.
|
||||
/// </summary>
|
||||
public bool IsLayerListEmpty => !LayerRows.OfType<Controls.ControlViewModel>().Any();
|
||||
|
||||
/// <summary>레이어 목록이 비어 보이는 이유</summary>
|
||||
/// <summary>레이어 목록이 비어 보이는 이유 — 걸러진 것인지, 애초에 없는 것인지</summary>
|
||||
public string LayerEmptyText =>
|
||||
SelectedPage is null || SelectedPage.Controls.Count == 0
|
||||
? "이 페이지에는 컨트롤이 없습니다"
|
||||
Pages.All(p => p.Controls.Count == 0)
|
||||
? "아직 컨트롤이 없습니다"
|
||||
: "검색·필터에 맞는 항목이 없습니다";
|
||||
|
||||
/// <summary>타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단</summary>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다.
|
||||
///
|
||||
/// 컨트롤 이름(Id)은 <b>문서 안에서 유일해야 한다</b> — 액션 대상·입력 파라미터가 이름으로 서로를
|
||||
/// 가리키고 CalcBox 수식도 이름으로 합산하므로, 같은 이름이 두 페이지에 있으면 런타임이 어느 쪽을
|
||||
/// 잡을지 알 수 없다. 그래서 붙여넣기와 같은 경로로 전부 새 이름을 발급한다.
|
||||
/// 그룹도 복제본끼리 다시 묶는다(원본 그룹에 끼면 한쪽을 클릭할 때 반대편까지 딸려 온다).
|
||||
/// </summary>
|
||||
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<string, string>(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;
|
||||
}
|
||||
|
||||
/// <summary>페이지 삭제(확인 후) — 최소 1페이지 유지</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>선택 페이지 삭제(확인 후) — 최소 1페이지 유지</summary>
|
||||
public void RemoveSelectedPage()
|
||||
{
|
||||
@@ -1165,6 +1228,87 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 같은 부모 안에서 임의 위치로 z-order 이동 — 레이어 목록 드래그 재정렬의 모델 연산.
|
||||
///
|
||||
/// <see cref="ReorderSelection"/> 은 맨 앞/맨 뒤 두 극단뿐이고 컨테이너 자식은 아예 건너뛴다.
|
||||
/// 여기서는 최상위든 컨테이너 자식이든 제 부모 컬렉션 안에서 원하는 자리로 옮긴다.
|
||||
/// 부모가 다른 컨트롤 사이의 이동(재부모화)은 하지 않는다 — 좌표계가 바뀌는 별개의 연산이다.
|
||||
///
|
||||
/// 좌표계 주의: VM 컬렉션은 <b>그리기 순서</b>(마지막이 최상위), 모델은 <b>XML 순서</b>(index 0 이
|
||||
/// 최상위)로 서로 역순이다. 두 리스트를 같이 옮기되 index 를 뒤집어 대응시킨다.
|
||||
/// </summary>
|
||||
/// <param name="viewModel">옮길 컨트롤</param>
|
||||
/// <param name="newDrawIndex">목표 위치(부모의 VM 컬렉션 = 그리기 순서 기준 index)</param>
|
||||
/// <returns>실제로 순서가 바뀌었으면 true</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 목록 드래그 재정렬 — <paramref name="source"/> 를 <paramref name="target"/> 의
|
||||
/// 위(<paramref name="above"/>=true) 또는 아래로 옮긴다.
|
||||
///
|
||||
/// 목록은 z-order 최상위가 맨 위인데 VM 컬렉션은 그리기 순서(마지막이 최상위)라 방향이 반대다.
|
||||
/// 그래서 "목록에서 위" = "VM 컬렉션에서 뒤"로 뒤집어 계산한다.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>컨트롤이 속한 부모의 (VM 컬렉션, 모델 컬렉션) — 최상위면 페이지, 아니면 컨테이너</summary>
|
||||
private (System.Collections.ObjectModel.ObservableCollection<ControlViewModel>? ViewModels, List<ControlElement>? 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);
|
||||
}
|
||||
|
||||
/// <summary>방향키 넛지 — 400ms 코얼레스 Undo</summary>
|
||||
public void NudgeSelection(double dx, double dy)
|
||||
{
|
||||
@@ -1296,17 +1440,23 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
? name
|
||||
: LayerGroupViewModel.FallbackName;
|
||||
|
||||
/// <summary>그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다</summary>
|
||||
/// <summary>그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다(그 경로도 되돌릴 수 있어야 한다)</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>그룹 펼침 상태 — 기본은 펼침</summary>
|
||||
@@ -1397,29 +1547,25 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
RebuildLayerRows();
|
||||
}
|
||||
|
||||
/// <summary>활성 페이지에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순</summary>
|
||||
/// <summary>문서에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순</summary>
|
||||
public List<(string Type, int Count)> LayerTypeCandidates()
|
||||
{
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
void Visit(IEnumerable<ControlViewModel> 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<ControlViewModel>());
|
||||
return counts.OrderByDescending(p => p.Value).ThenBy(p => p.Key, StringComparer.Ordinal)
|
||||
.Select(p => (p.Key, p.Value)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 목록 재구성 — 그룹 폴더 + 들여쓴 멤버 + 컨테이너 자식을 한 줄 시퀀스로 평탄화한다.
|
||||
/// 순서는 페이지의 <b>그리기 순서</b>를 그대로 따른다(캔버스에서 뒤에 있는 것이 목록 위).
|
||||
/// 레이어 목록 재구성 — <b>문서의 전 페이지</b>를 한 아웃라인으로 평탄화한다.
|
||||
/// 페이지 헤더 → (펼쳐져 있으면) 그룹 폴더·컨트롤·컨테이너 자식 → 다음 페이지 → 맨 끝에 '페이지 추가'.
|
||||
///
|
||||
/// 순서는 <b>z-order 최상위가 맨 위</b>다(Figma·[200] 규약). PageViewModel.Controls 는 이미
|
||||
/// 그리기 순서(마지막이 최상위)라 역순으로 훑는다 — 캔버스에서 위에 겹쳐 보이는 컨트롤을
|
||||
/// 목록에서도 위에서 찾게 된다.
|
||||
/// </summary>
|
||||
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<string>(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();
|
||||
}
|
||||
|
||||
/// <summary>페이지 직속 컨트롤·그룹 폴더의 들여쓰기 — 페이지 노드 아래라 항상 한 칸 들어간다</summary>
|
||||
private const double PageChildIndent = 12;
|
||||
|
||||
/// <summary>그룹 멤버 들여쓰기 — 페이지 직속보다 한 단계 더</summary>
|
||||
private const double GroupMemberIndent = 32;
|
||||
|
||||
/// <summary>컨테이너(패널/그룹박스) 자식의 추가 들여쓰기</summary>
|
||||
private const double ContainerChildIndent = 16;
|
||||
|
||||
/// <summary>
|
||||
/// 선택 강조 3상태 + 연속 블록 병합 계산 — 목록을 다시 만들지 않고 <b>색만</b> 갱신한다.
|
||||
///
|
||||
/// 선택이 바뀔 때마다 평탄화를 다시 돌리면 1,067행 서식에서 클릭 한 번에 목록 전체가 재생성된다.
|
||||
/// 그래서 강조는 별도 경로로 분리했다.
|
||||
///
|
||||
/// 그룹 집계는 <b>전 페이지</b>를 대상으로 한다 — 그룹 식별자는 페이지를 넘나들 수 있고,
|
||||
/// 폴더가 파랑이 되려면 "그 그룹의 모든 멤버"가 선택돼 있어야 한다.
|
||||
/// </summary>
|
||||
public void UpdateLayerHighlight()
|
||||
{
|
||||
var total = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var selected = new Dictionary<string, int>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 목록 재구성 중 — 이때 ListBox 가 쏟아내는 선택 해제는 사용자의 조작이 아니다.
|
||||
/// (<c>LayerRows.Clear()</c> 가 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택 집합이 바뀌었을 때 — 그룹 폴더의 눈·자물쇠 표시를 다시 계산</summary>
|
||||
/// <summary>선택 집합이 바뀌었을 때 — 강조 3상태와 그룹 폴더의 눈·자물쇠 표시를 다시 계산</summary>
|
||||
private void LayerSelectionChanged()
|
||||
{
|
||||
UpdateLayerHighlight();
|
||||
foreach (var group in LayerRows.OfType<LayerGroupViewModel>())
|
||||
{
|
||||
group.NotifyFlagsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>레이어 행(컨트롤 또는 그룹 폴더) → 선택 대상 컨트롤 목록</summary>
|
||||
/// <summary>레이어 행 → 선택 대상 컨트롤 목록. 페이지 행·추가 행은 선택 대상이 아니라 빈 목록</summary>
|
||||
public static IEnumerable<ControlViewModel> ControlsOfLayerRow(object? row) => row switch
|
||||
{
|
||||
ControlViewModel control => new[] { control },
|
||||
@@ -1535,6 +1761,36 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
_ => Enumerable.Empty<ControlViewModel>(),
|
||||
};
|
||||
|
||||
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
|
||||
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary>
|
||||
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
|
||||
|
||||
/// <summary>페이지 펼침/접기</summary>
|
||||
public void SetPageExpanded(PageViewModel page, bool expanded)
|
||||
{
|
||||
if (expanded ? collapsedPages.Remove(page) : collapsedPages.Add(page))
|
||||
{
|
||||
RequestLayerRebuild();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>페이지 헤더 행 클릭 — 활성 페이지로 삼고 캔버스를 그 페이지로 옮긴다</summary>
|
||||
public void ActivatePage(PageViewModel page)
|
||||
{
|
||||
if (!ReferenceEquals(SelectedPage, page))
|
||||
{
|
||||
SelectedPage = page;
|
||||
}
|
||||
foreach (var header in LayerRows.OfType<LayerPageViewModel>())
|
||||
{
|
||||
header.NotifyStateChanged();
|
||||
}
|
||||
ScrollToPageRequested?.Invoke(page);
|
||||
}
|
||||
|
||||
/// <summary>캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다</summary>
|
||||
public event Action<PageViewModel>? ScrollToPageRequested;
|
||||
|
||||
/// <summary>잘라내기 — 복사 후 삭제</summary>
|
||||
public void CutSelection()
|
||||
{
|
||||
|
||||
@@ -10,12 +10,15 @@ namespace SheetMe.Designer.ViewModels;
|
||||
/// 새로 만들어진다 — 펼침 상태·이름은 여기에 두지 않고 각각
|
||||
/// <see cref="DesignerViewModel"/> 의 접힌 그룹 집합과 <c>FormDocument.GroupNames</c> 가 소유한다.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>숨김 아이콘명 — 하나라도 숨겨져 있으면 숨긴 모양</summary>
|
||||
public string HiddenIcon => Members.Any(m => m.IsHiddenFlag) ? "eye-off" : "eye";
|
||||
|
||||
/// <summary>강조 밴드 위 아이콘 색 — 짙은 파랑 채움 위에서 보조색은 묻힌다</summary>
|
||||
private string RowIconKey => highlight == LayerHighlight.Blue ? "B.OnAccent" : "B.Muted";
|
||||
|
||||
/// <summary>그룹 잠금 아이콘 사양</summary>
|
||||
public string LockIconSpec => $"{LockIcon}|13|{RowIconKey}";
|
||||
|
||||
/// <summary>그룹 숨김 아이콘 사양</summary>
|
||||
public string HiddenIconSpec => $"{HiddenIcon}|13|{RowIconKey}";
|
||||
|
||||
/// <summary>그룹 접기 셰브론 사양</summary>
|
||||
public string ChevronSpec => $"{ChevronIcon}|12|{RowIconKey}";
|
||||
|
||||
/// <summary>이름 인라인 편집 중 — 더블클릭으로 켠다</summary>
|
||||
public bool IsEditing
|
||||
{
|
||||
@@ -111,6 +127,47 @@ public sealed class LayerGroupViewModel : ViewModelBase
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region ILayerRow
|
||||
/// <summary>폴더 행 강조 — 멤버가 전부 선택됐을 때만 파랑</summary>
|
||||
public LayerHighlight Highlight => highlight;
|
||||
|
||||
/// <summary>위 행과 이어진 강조 블록인가</summary>
|
||||
public bool MergeTop => mergeTop;
|
||||
|
||||
/// <summary>아래 행과 이어진 강조 블록인가 — 폴더 바로 아래가 멤버라 보통 참이다</summary>
|
||||
public bool MergeBottom => mergeBottom;
|
||||
|
||||
/// <summary>폴더 행도 클릭으로 그룹 전체를 고를 수 있다</summary>
|
||||
public bool IsSelectableRow => true;
|
||||
|
||||
/// <summary>강조 상태 적용</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>폴더 아이콘 사양 — 강조 밴드 위에서는 흰색으로 뒤집는다</summary>
|
||||
public string FolderIconSpec => "folder|14|" + highlight switch
|
||||
{
|
||||
LayerHighlight.Blue => "B.OnAccent",
|
||||
LayerHighlight.Navy => "B.AccentText",
|
||||
_ => "B.Accent",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>멤버 플래그가 밖에서 바뀌었을 때 아이콘·토글 상태 재계산</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>이름이 없을 때의 기본 표시</summary>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 행의 강조 상태 — [200]SheetMe 의 3상태(OutlineHL)를 그대로 옮긴 것.
|
||||
/// </summary>
|
||||
public enum LayerHighlight
|
||||
{
|
||||
/// <summary>강조 없음</summary>
|
||||
None,
|
||||
|
||||
/// <summary>선택된 행 — 채워진 강조 밴드</summary>
|
||||
Blue,
|
||||
|
||||
/// <summary>그룹이 통째로 선택됐을 때의 멤버 행 — 폴더가 파랑을 가져가고 멤버는 짙은 서브트리 배경</summary>
|
||||
Navy,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 목록 행 공통 — 강조 상태와 연속 병합 정보를 노출한다.
|
||||
///
|
||||
/// 선택 표시를 <c>ListBoxItem.IsSelected</c> 가 아니라 이 값으로 그리는 이유:
|
||||
/// 그룹 폴더(파랑)와 그 멤버(네이비)는 <b>색이 다른데도 하나의 이어진 블록</b>이어야 하고,
|
||||
/// 페이지 행처럼 선택 자체가 불가능하면서 배경은 따로 가지는 행도 있다.
|
||||
/// IsSelected 로는 이 셋을 한 축으로 표현할 수 없다.
|
||||
/// </summary>
|
||||
public interface ILayerRow
|
||||
{
|
||||
/// <summary>이 행의 강조 상태</summary>
|
||||
LayerHighlight Highlight { get; }
|
||||
|
||||
/// <summary>바로 위 행도 강조 상태다 — 위쪽 모서리를 각지게 해 블록을 잇는다</summary>
|
||||
bool MergeTop { get; }
|
||||
|
||||
/// <summary>바로 아래 행도 강조 상태다 — 아래쪽 모서리를 각지게 하고 1px 겹친다</summary>
|
||||
bool MergeBottom { get; }
|
||||
|
||||
/// <summary>목록에서 선택 대상인가 — 페이지 행·추가 행은 false</summary>
|
||||
bool IsSelectableRow { get; }
|
||||
|
||||
/// <summary>강조/병합 상태 적용(디자이너가 일괄 계산해 밀어 넣는다)</summary>
|
||||
void ApplyHighlight(LayerHighlight highlight, bool mergeTop, bool mergeBottom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 목록의 페이지 헤더 행 — [200] PageHeaderRow 대응.
|
||||
///
|
||||
/// 페이지를 목록 바깥의 별도 섹션이 아니라 아웃라인의 최상위 노드로 둔다. 부수 효과가 하나 있는데
|
||||
/// 의도한 것이다: 페이지 행은 항상 <see cref="LayerHighlight.None"/> 이라 연속 강조 블록이
|
||||
/// 페이지 경계에서 저절로 끊긴다([200] 이 Separator 노드로 하던 일).
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>대상 페이지</summary>
|
||||
public PageViewModel Page { get; }
|
||||
|
||||
/// <summary>표시 제목 — "페이지 1"</summary>
|
||||
public string Title => $"페이지 {Page.Index + 1}";
|
||||
|
||||
/// <summary>컨트롤 수 표시 — 컨테이너 자식까지 센다</summary>
|
||||
public string CountText => $"({CountControls(Page.Controls)})";
|
||||
|
||||
/// <summary>활성 페이지인가 — 배경 강조와 아이콘 색이 갈린다</summary>
|
||||
public bool IsCurrent => ReferenceEquals(designer.SelectedPage, Page);
|
||||
|
||||
/// <summary>컨트롤이 하나도 없는가 — "(비어 있음)" 둘째 줄</summary>
|
||||
public bool IsEmpty => Page.Controls.Count == 0;
|
||||
|
||||
/// <summary>삭제 버튼을 그릴지 — 마지막 한 장은 지울 수 없다</summary>
|
||||
public bool CanDelete => designer.Pages.Count > 1;
|
||||
|
||||
/// <summary>펼침 상태</summary>
|
||||
public bool IsExpanded
|
||||
{
|
||||
get => designer.IsPageExpanded(Page);
|
||||
set
|
||||
{
|
||||
designer.SetPageExpanded(Page, value);
|
||||
OnPropertyChanged(nameof(IsExpanded));
|
||||
OnPropertyChanged(nameof(ChevronIcon));
|
||||
OnPropertyChanged(nameof(ChevronSpec));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>펼침 셰브론 아이콘명</summary>
|
||||
public string ChevronIcon => IsExpanded ? "chevron-down" : "chevron-right";
|
||||
|
||||
/// <summary>펼침 셰브론 사양 — 페이지 행은 강조되지 않으므로 색이 고정이다</summary>
|
||||
public string ChevronSpec => $"{ChevronIcon}|12|B.Muted";
|
||||
|
||||
/// <summary>페이지 아이콘 사양 — 활성 페이지만 강조색</summary>
|
||||
public string PageIconSpec => IsCurrent ? "file-text|13|B.Accent" : "file-text|13|B.Muted";
|
||||
|
||||
/// <summary>첫 페이지인가 — 위 여백을 줄지 판단</summary>
|
||||
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
|
||||
/// <summary>표시 상태 재계산 통지 — 활성 페이지 전환·컨트롤 증감 후</summary>
|
||||
public void NotifyStateChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(Title));
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
OnPropertyChanged(nameof(IsCurrent));
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
OnPropertyChanged(nameof(CanDelete));
|
||||
OnPropertyChanged(nameof(PageIconSpec));
|
||||
OnPropertyChanged(nameof(IsFirst));
|
||||
}
|
||||
|
||||
/// <summary>컨테이너 자식까지 포함한 컨트롤 수</summary>
|
||||
private static int CountControls(IEnumerable<ControlViewModel> items)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
total++;
|
||||
if (item is ContainerViewModel container)
|
||||
{
|
||||
total += CountControls(container.Children);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 목록 맨 아래의 '+ 페이지 추가' 행 — [200] AddPageRow 대응.
|
||||
/// 페이지 섹션을 없앤 자리에서 페이지 추가 진입점을 대신한다.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user