diff --git a/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs b/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs
index 500febf..0f677f8 100644
--- a/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs
+++ b/src/SheetMe.Designer/Behaviors/LayerListBehavior.cs
@@ -138,6 +138,58 @@ public static class LayerListBehavior
{
syncing.Remove(list);
}
+ RevealPrimary(list, designer);
+ }
+
+ ///
+ /// 캔버스에서 고른 것을 목록이 따라오게 한다 — 조상을 펴고 그 행으로 스크롤한다.
+ ///
+ /// 왜 필요한가. 1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면
+ /// 목록에는 아무 변화가 없는 경우가 흔했다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어
+ /// 행 자체가 만들어지지 않았거나. 강조를 해도 눈에 안 보이니 선택이 안 된 것으로 읽힌다.
+ /// 반대 방향(목록 더블클릭 → 캔버스)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
+ ///
+ /// 펴기는 한 번만 시도한다. 편 결과로 목록이 다시 만들어지면 재구성 이벤트가
+ /// 이 경로를 한 번 더 부르는데, 그때는 행이 있으므로 스크롤만 하고 끝난다.
+ /// 행이 끝내 없으면(검색·타입 필터에 걸려 빠진 경우) 아무것도 하지 않는다 —
+ /// 필터를 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다.
+ ///
+ private static void RevealPrimary(ListBox list, DesignerViewModel designer)
+ {
+ if (designer.Selection.Primary is not { } primary)
+ {
+ return;
+ }
+ if (RowOf(list, primary) is { } row)
+ {
+ list.ScrollIntoView(row);
+ return;
+ }
+ if (!designer.ExpandLayerPathTo(primary))
+ {
+ return; // 펼 것이 없는데도 행이 없다 = 필터에 걸린 것이다
+ }
+ // 재구성은 비동기로 도는 경로가 있다 — 한 틱 뒤에 다시 찾아본다
+ list.Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (RowOf(list, primary) is { } revealed)
+ {
+ list.ScrollIntoView(revealed);
+ }
+ }), System.Windows.Threading.DispatcherPriority.Background);
+ }
+
+ /// 이 컨트롤을 담고 있는 목록 행 — 없으면 null(접혔거나 필터에 걸렸다)
+ private static object? RowOf(ListBox list, ControlViewModel control)
+ {
+ foreach (var row in list.Items)
+ {
+ if (DesignerViewModel.ControlsOfLayerRow(row).Contains(control))
+ {
+ return row;
+ }
+ }
+ return null;
}
/// 현재 선택으로부터 강조돼야 할 행들
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index a1f9433..e952da2 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -1236,6 +1236,22 @@ public static class EditSmoke
fresh.LayerSubText == fresh.Id,
$"'{fresh.LayerName}' / '{fresh.LayerSubText}'");
+ // 접힌 조상 때문에 행 자체가 없는 상태를 편다.
+ // 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다 —
+ // 종이에서 클릭했는데 목록에 아무 반응이 없으면 어느 쪽이 맞는지 알 수 없다.
+ wrapped.IsLayerExpanded = false;
+ tree.RebuildLayerRows();
+ var hiddenChild = wrapped.Children[0];
+ Check("자동 스크롤: 접히면 자식 행이 목록에 없다",
+ !tree.LayerRows.Any(r => DesignerViewModel.ControlsOfLayerRow(r).Contains(hiddenChild)));
+
+ Check("자동 스크롤: 조상을 펴면 true 를 돌려준다", tree.ExpandLayerPathTo(hiddenChild));
+ tree.RebuildLayerRows();
+ Check("자동 스크롤: 편 뒤에는 행이 생긴다",
+ tree.LayerRows.Any(r => DesignerViewModel.ControlsOfLayerRow(r).Contains(hiddenChild)));
+ Check("자동 스크롤: 이미 펴져 있으면 false(무한 재구성 방지)",
+ !tree.ExpandLayerPathTo(hiddenChild));
+
var bare = new ControlElement
{
Type = "TextBox", Id = "TextBox9",
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index fcdffbd..2cb669e 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -2780,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase
/// 페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 페이지 인스턴스로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)
+ ///
+ /// 이 컨트롤의 행이 목록에 나타나도록 조상을 전부 편다 — 실제로 편 것이 있으면 true.
+ ///
+ /// 왜 필요한가. 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다.
+ /// 그래서 종이 한가운데 컨트롤을 클릭했는데 그 조상이 접혀 있으면
+ /// 목록에는 행 자체가 없어 아무 반응이 없다 — 사용자 눈에는 선택이 안 된 것으로 보이고,
+ /// 캔버스와 목록 중 어느 쪽이 맞는지 판단할 단서를 잃는다.
+ /// 접기·검색·그룹을 쓸수록 어긋나므로, 기존 기능이 오히려 신뢰를 깎고 있었다.
+ ///
+ /// 반대 방향(목록 더블클릭 → 캔버스로 스크롤)은 로 이미 있다 —
+ /// 이것은 그 역방향을 잇는 일이지 새 기능이 아니다.
+ ///
+ public bool ExpandLayerPathTo(ControlViewModel viewModel)
+ {
+ var changed = false;
+
+ // 페이지 마디 — 공개 메서드를 쓴다(목록 재구성 요청까지 함께 도는 경로다)
+ if (PageOf(viewModel) is { } page && !IsPageExpanded(page))
+ {
+ SetPageExpanded(page, true);
+ changed = true;
+ }
+ // 그룹 폴더 — 그룹 멤버는 폴더가 접히면 행이 안 만들어진다
+ if (viewModel.Model.GroupId is { } groupId && !IsGroupExpanded(groupId))
+ {
+ SetGroupExpanded(groupId, true);
+ changed = true;
+ }
+ // 컨테이너 조상 — 자기 자신은 펴지 않는다(자기 행은 이미 있다)
+ for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent)
+ {
+ if (!parent.IsLayerExpanded)
+ {
+ parent.IsLayerExpanded = true;
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// 페이지 펼침/접기