레이어 패널을 [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
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user