From 0e30aa39c6aa8c0c057d7291c7857ad02f3f1253 Mon Sep 17 00:00:00 2001 From: Msystech Date: Fri, 14 Aug 2026 13:09:59 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B7=B8=EB=A3=B9=EC=9D=84=20=EC=A7=84?= =?UTF-8?q?=EC=A7=9C=20=ED=8C=A8=EB=84=90=EB=A1=9C=20=E2=80=94=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=A9=B4=20=EC=82=B4=EC=95=84=EB=82=A8?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 그룹은 GroupId 라는 편집 전용 표시였고, 저장 포맷에 담을 자리가 없어 다시 열면 풀려 있었다. 레거시도 같았다(clsDesignTimeGroupService.vb:3-4 — "디자인 시에만 쓰는 임시 그룹, 저장 시 Group 정의 없이 컨트롤만 저장됨"). 다만 우리는 레이어 폴더로, 이름까지 붙이는 1급 개념으로 보여 줘서 사라질 때의 배신감이 더 컸다. 묶으면 실제 Panel 을 만들어 멤버를 그 자식으로 옮긴다. 패널은 레거시에 실재하는 컨트롤이라 저장·재열기·레거시 디자이너 재저장 전부에서 그대로 살아남는다. 검증: EMR 런타임의 5개 소비 경로(태그 채움·MDataTable·저장·탭순서·인쇄)가 전부 컨테이너를 재귀하고, 운영 1,273건 중 492건이 이미 컨테이너 안에 컨트롤을 담고 동작 중이다. XML 스키마도 원래 구조다. 그 대신 그룹이 '문서를 바꾸는 조작'이 된다. 조사에서 나온 부작용 넷을 가드로 막는다. · 라디오는 묶지 않는다. 레거시 RadioButton 에는 GroupName 이 없어 부모가 곧 배타 단위다. '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다. 캔버스에는 아무 변화도 안 보이고 어떤 게이트도 못 잡는 종류라 아예 막았다. · 멤버 사이에 남이 끼어 있으면 묶지 않는다. WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 z 블록이 되는데, 사이에 낀 것이 앞뒤로 밀려 쌓임 순서가 바뀐다. 샘플 1장 실측에서 '이웃 둘 묶기' 35쌍 중 5쌍(14%)이 실제로 바뀌었다. · 패널은 멤버 bbox 에 정확히 맞춘다. WinForms 패널은 자식을 하드 클리핑하는데 인쇄 경로에는 클리핑이 0건이다 — 넘치면 화면에서만 사라지고 인쇄에는 나오는 컨트롤이 만들어진다. · 좌표를 감쌀 때 정수로 확정한다. 저장 포맷이 정수만 쓰는데 부모 원점과 자식 상대좌표를 따로 반올림하면 Location 과 LocationOnBase 가 축당 1px 어긋난 파일이 나온다. 탭 순서를 컨테이너 단위로 고쳤다. TabIndex 는 Windows 표준 탐색이라 컨테이너 범위인데, 문서 평면에 0,100,200… 을 뿌리고 있어서 패널 안의 것들이 저희끼리만 겨루고 탭이 그 패널을 통째로 지나친 뒤에야 들어갔다. 이제 부모별로 매기고 패널 자신에게는 제 자식 중 가장 이른 순번을 준다. Ctrl+Shift+G 는 패널 풀기가 된다 — 자식을 최상위로 되돌리고 좌표를 절대값으로 복원하며 패널이 있던 z 자리에 그대로 끼워 넣어 쌓임 순서를 보존한다. 레거시 디자이너에는 이 조작이 없다 — 거기서 패널을 지우면 자식까지 함께 지워진다. 기존 문서는 건드리지 않는다(새로 묶을 때만 패널이 생긴다). 그래서 왕복 검사와 렌더는 무사하다 — 직전 판단에서 "즉시 파괴"라고 적었던 것은 과장이었고, 여기서 정정한다. --edit-smoke 의 그룹 검사를 다시 썼다. 폴더 행(LayerGroupViewModel) 기반 단언 9건은 그 표현이 사라져 삭제했고, 패널 구조·가드 2종·풀기 복원으로 8건을 새로 넣었다. 그룹 검사는 별도 문서에서 돈다 — 메인 designer 의 최상위 구성이 바뀌면 뒤따르는 Find() 들이 전부 무너진다(실제로 그렇게 깨져서 분리했다). 게이트: 테스트 267/267, --edit-smoke 0실패, --query-popup 17/17, --maxrect 10/10, --db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. 남은 것: 레이어 패널에서 '그룹용 패널'과 사용자가 만든 패널이 똑같이 보인다. 구분이 필요하면 별건으로 다룬다. Co-Authored-By: Claude Opus 5 --- src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 183 +++++++++--------- .../ViewModels/DesignerViewModel.cs | 174 ++++++++++++++++- 2 files changed, 259 insertions(+), 98 deletions(-) diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index a6ab162..e0ceb38 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -216,21 +216,78 @@ public static class EditSmoke designer.DeleteSelection(); Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0); - // 20) 그룹/해제 — 그룹 단위 클릭 선택 - designer.Selection.Set(new[] { Find("TextBox"), Find("Label") }); - designer.GroupSelection(); - designer.Selection.Clear(); - var member = Find("TextBox"); - var memberPoint = new Point(member.X + member.Width / 2, member.Y + member.Height / 2); - designer.Interaction.PointerDown(memberPoint, none); - designer.Interaction.PointerUp(memberPoint, none); - Check("그룹 클릭 → 그룹 전체 선택", designer.Selection.Items.Count == 2, - $"실제 {designer.Selection.Items.Count}"); - designer.UngroupSelection(); - designer.Selection.Clear(); - designer.Interaction.PointerDown(memberPoint, none); - designer.Interaction.PointerUp(memberPoint, none); - Check("그룹 해제 후 단일 선택", designer.Selection.Items.Count == 1); + // 20) 그룹 — 이제 진짜 Panel 로 감싼다. 별도 문서에서 본다(메인 designer 의 최상위 구성이 바뀌면 + // 뒤따르는 Find("TextBox") 들이 전부 무너진다 — 실제로 그렇게 깨져서 분리했다). + { + var grp = new DesignerViewModel(business.CreateNew()); + ControlViewModel Put(string type, double x, double y, double w, double h) + { + grp.AddControlAt(type, new Point(x, y)); + var vm = grp.Pages[0].Controls[^1]; + vm.X = x; + vm.Y = y; + vm.Width = w; + vm.Height = h; + return vm; + } + + var a = Put("Label", 100, 100, 60, 20); + var b = Put("TextBox", 200, 140, 80, 24); + grp.Selection.Set(new List { a, b }, a); + grp.GroupSelection(); + + var wrap = grp.Pages[0].Controls.OfType().FirstOrDefault(c => c.Type == "Panel"); + Check("그룹: 패널이 생긴다", wrap is not null); + Check("그룹: 멤버가 패널 자식이 된다", + wrap is not null && wrap.Children.Count == 2 + && !grp.Pages[0].Controls.Contains(a) && !grp.Pages[0].Controls.Contains(b), + $"자식 {wrap?.Children.Count} 최상위 {grp.Pages[0].Controls.Count}"); + Check("그룹: 패널이 멤버 bbox 를 덮는다", + wrap is not null && wrap.X == 100 && wrap.Y == 100 + && wrap.Width == 180 && wrap.Height == 64, + $"실제 ({wrap?.X},{wrap?.Y},{wrap?.Width},{wrap?.Height})"); + Check("그룹: 자식 좌표가 부모 상대로 바뀐다", + wrap is not null && wrap.Children.Any(c => c.X == 0 && c.Y == 0) + && wrap.Children.Any(c => c.X == 100 && c.Y == 40), + string.Join(" ", wrap?.Children.Select(c => $"({c.X},{c.Y})") ?? Array.Empty())); + + // 풀기 — 절대좌표 복원 + grp.Selection.SetSingle(wrap!); + grp.UngroupSelection(); + Check("그룹 풀기: 최상위로 돌아온다", + grp.Pages[0].Controls.Count == 2 + && !grp.Pages[0].Controls.OfType().Any(), + $"최상위 {grp.Pages[0].Controls.Count}"); + Check("그룹 풀기: 좌표가 절대값으로 복원된다", + grp.Pages[0].Controls.Any(c => c.X == 100 && c.Y == 100) + && grp.Pages[0].Controls.Any(c => c.X == 200 && c.Y == 140), + string.Join(" ", grp.Pages[0].Controls.Select(c => $"({c.X},{c.Y})"))); + + // 가드 ① 라디오는 부모가 곧 배타 단위 — 일부만 묶으면 '택1'이 갈라진다 + var radioDoc = new DesignerViewModel(business.CreateNew()); + radioDoc.AddControlAt("RadioButton", new Point(100, 100)); + radioDoc.AddControlAt("RadioButton", new Point(100, 140)); + radioDoc.Selection.Set(radioDoc.Pages[0].Controls.ToList(), radioDoc.Pages[0].Controls[0]); + var beforeRadio = radioDoc.Pages[0].Controls.Count; + radioDoc.GroupSelection(); + Check("그룹 가드: 라디오는 묶이지 않는다", + radioDoc.Pages[0].Controls.Count == beforeRadio + && !radioDoc.Pages[0].Controls.Any(c => c.Type == "Panel")); + + // 가드 ② 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다 + var zDoc = new DesignerViewModel(business.CreateNew()); + zDoc.AddControlAt("Label", new Point(100, 100)); + zDoc.AddControlAt("Label", new Point(120, 120)); + zDoc.AddControlAt("Label", new Point(140, 140)); + var zTop = zDoc.Pages[0].Controls[0]; + var zBottom = zDoc.Pages[0].Controls[2]; + zDoc.Selection.Set(new List { zTop, zBottom }, zTop); + var beforeZ = zDoc.Pages[0].Controls.Count; + zDoc.GroupSelection(); + Check("그룹 가드: 사이에 남이 끼면 묶지 않는다", + zDoc.Pages[0].Controls.Count == beforeZ + && !zDoc.Pages[0].Controls.Any(c => c.Type == "Panel")); + } // 20-1) 인라인 텍스트 편집 — 더블클릭 요청 이벤트 + 커밋 var inlineTarget = Find("Label"); @@ -278,11 +335,13 @@ public static class EditSmoke lay.Selection.Set(new[] { Pick("TextBox"), Pick("Label") }); lay.GroupSelection(); - var groupRow = lay.LayerRows.OfType().FirstOrDefault(); - Check("레이어: 그룹 폴더 행 1개", lay.LayerRows.OfType().Count() == 1); - Check("레이어: 폴더 멤버 2 + 기본 이름", - groupRow is { Members.Count: 2 } && groupRow.Name.StartsWith("그룹"), - $"실제 {groupRow?.Members.Count} / {groupRow?.Name}"); + // 그룹은 이제 진짜 패널이라 폴더 행(LayerGroupViewModel)이 아니라 컨테이너 행으로 나온다. + // 폴더 행은 GroupId 시절의 표현이었고, 그 시절 그룹은 저장하면 사라졌다. + var wrapRow = lay.LayerRows.OfType().FirstOrDefault(c => c.Type == "Panel"); + Check("레이어: 그룹이 패널 행으로 나온다", wrapRow is not null); + Check("레이어: 패널 행 아래 멤버 2", + wrapRow is not null && wrapRow.Children.Count == 2, + $"실제 {wrapRow?.Children.Count}"); // 아웃라인은 페이지 헤더로 시작하고 '페이지 추가' 행으로 끝난다 // 페이지는 별도 구획으로 나갔다 — 한 장짜리 문서의 트리에는 페이지 마디가 없다 @@ -295,12 +354,12 @@ public static class EditSmoke lay.LayerRows.OfType().Where(r => r is LayerPageViewModel) .All(r => !r.IsSelectableRow)); - // 폴더 바로 뒤에 멤버가 들여쓰기된 채 이어져야 한다(평탄화 시퀀스) - var folderIndex = lay.LayerRows.IndexOf(groupRow!); - Check("레이어: 폴더 뒤에 멤버가 들여쓰여 이어짐", + // 패널 바로 뒤에 자식이 들여쓰기된 채 이어져야 한다(평탄화 시퀀스) + var folderIndex = wrapRow is null ? -1 : lay.LayerRows.IndexOf(wrapRow); + Check("레이어: 패널 뒤에 자식이 들여쓰여 이어짐", folderIndex >= 0 && lay.LayerRows.Skip(folderIndex + 1).Take(2) - .OfType().Count(c => c.LayerIndent == 20) == 2); + .OfType().Count(c => c.LayerIndent > 0) == 2); Check("레이어: 한 장이면 최상위 들여쓰기가 0", lay.LayerRows.OfType() .Where(c => c.Model.GroupId is null && c.Parent is null) @@ -312,33 +371,9 @@ public static class EditSmoke ReferenceEquals(lay.Document.Pages[0].Controls[0], topControl.Model), $"실제 맨 위 {topControl.Id} / 모델 첫째 {lay.Document.Pages[0].Controls[0].Id}"); - // 접으면 멤버 행이 빠지고, 펴면 되돌아온다 - var expandedCount = lay.LayerRows.Count; - groupRow!.IsExpanded = false; - lay.RebuildLayerRows(); - Check("레이어: 접으면 멤버 행 제거", lay.LayerRows.Count == expandedCount - 2, - $"실제 {lay.LayerRows.Count} 기대 {expandedCount - 2}"); - groupRow = lay.LayerRows.OfType().First(); - groupRow.IsExpanded = true; - lay.RebuildLayerRows(); - Check("레이어: 펴면 멤버 행 복귀", lay.LayerRows.Count == expandedCount); - - // 그룹 일괄 숨김이 전 멤버에 퍼진다 - groupRow = lay.LayerRows.OfType().First(); - groupRow.IsHiddenFlag = true; - Check("레이어: 그룹 숨김 → 전 멤버", groupRow.Members.All(m => m.IsHiddenFlag)); - groupRow.IsHiddenFlag = false; - Check("레이어: 그룹 표시 복귀", groupRow.Members.All(m => !m.IsHiddenFlag)); - - // 그룹 이름은 문서가 소유 — 비우면 기본값으로 되돌아간다 - groupRow.Name = "머리글 묶음"; - Check("레이어: 그룹 개명", lay.GroupNameOf(groupRow.GroupId) == "머리글 묶음"); - groupRow.Name = string.Empty; - Check("레이어: 이름 비우면 기본값", lay.GroupNameOf(groupRow.GroupId) == "그룹"); - // 컨테이너 자식이 계층으로 보이고, 접으면 목록에서 빠진다 lay.AddControlAt("Panel", new Point(300, 600)); - var layerPanel = lay.Pages[0].Controls.OfType().First(); + var layerPanel = lay.Pages[0].Controls.OfType().Last(); lay.AddControlAt("Label", new Point(layerPanel.X + 20, layerPanel.Y + 20)); lay.RebuildLayerRows(); Check("레이어: 컨테이너 자식이 목록에 나옴", @@ -373,47 +408,17 @@ public static class EditSmoke lay.SetLayerTypeFilter(Array.Empty()); Check("레이어: 타입 필터 해제", !lay.HasLayerTypeFilter); - // 붙여넣은 복제본은 원본 그룹에 끼지 않는다(GroupId 재매핑) - groupRow = lay.LayerRows.OfType().First(); - var sourceGroupId = groupRow.Members[0].Model.GroupId; - lay.Selection.SetSingle(groupRow.Members[0]); - lay.CopySelection(); - lay.Paste(); - Check("레이어: 붙여넣기가 원본 그룹에 끼지 않음", - lay.Selection.Items[0].Model.GroupId != sourceGroupId, - $"원본 {sourceGroupId} 복제 {lay.Selection.Items[0].Model.GroupId}"); + // 패널 자식을 복사해 붙이면 최상위로 나온다 — 원본 패널에 끼면 안 된다 + var paneled = lay.LayerRows.OfType().FirstOrDefault(c => c.Type == "Panel"); + if (paneled is { Children.Count: > 0 }) + { + lay.Selection.SetSingle(paneled.Children[0]); + lay.CopySelection(); + lay.Paste(); + Check("레이어: 패널 자식 붙여넣기는 원본 패널에 끼지 않음", + lay.Selection.Items.Count > 0 && !ReferenceEquals(lay.Selection.Items[0].Parent, paneled)); + } - // 멤버가 1개로 줄면 그룹은 자동 해제되고 이름도 정리된다 - groupRow = lay.LayerRows.OfType().First(); - lay.Selection.SetSingle(groupRow.Members[1]); - lay.DeleteSelection(); - Check("레이어: 멤버 1개 남으면 그룹 자동 해제", - !lay.LayerRows.OfType().Any() - && lay.Pages[0].Controls.All(c => c.Model.GroupId is null) - && lay.Document.GroupNames.Count == 0); - - // 그룹은 편집 전용 상태다 — 저장 XML 에 흔적이 남으면 왕복 불변식이 깨진다 - lay.Selection.Set(lay.Pages[0].Controls.Take(2).ToList()); - lay.GroupSelection(); - Check("레이어: 그룹은 XML 에 나가지 않음", - !serializer.Write(lay.Document).Contains("GroupId", StringComparison.OrdinalIgnoreCase)); - - // 강조 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("레이어: 페이지 헤더는 항상 무강조", diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index cf33bc2..33fd5d1 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -932,9 +932,38 @@ public sealed class DesignerViewModel : ViewModelBase index++; vm.Model.Props.SetText("TabIndex", (999999 + index).ToString()); } + // TabIndex 는 컨테이너 범위다(Windows 표준 탐색). 문서 평면에 0,100,200… 을 뿌리면 + // 패널 안의 것들이 저희끼리만 겨루게 되어 탭이 그 패널을 통째로 지나친 뒤에야 들어간다. + // 그래서 부모별로 따로 매기고, 패널 자신에게는 제 자식 중 가장 이른 순번을 준다. + // Dictionary 는 null 키를 받지 않는다 — 최상위는 따로 센다 + var topRank = 0; + var byParent = new Dictionary(); + var panelRank = new Dictionary(); for (var i = 0; i < tabOrderSequence.Count; i++) { - tabOrderSequence[i].Model.Props.SetText("TabIndex", i == 0 ? "0" : (100 * i).ToString()); + var vm = tabOrderSequence[i]; + var parent = vm.Parent; + int rank; + if (parent is null) + { + rank = topRank++; + } + else + { + rank = byParent.TryGetValue(parent, out var next) ? next : 0; + byParent[parent] = rank + 1; + } + vm.Model.Props.SetText("TabIndex", rank == 0 ? "0" : (100 * rank).ToString()); + + // 패널 자신의 순번은 그 안에서 가장 먼저 클릭된 것의 자리다 + if (parent is not null && !panelRank.ContainsKey(parent)) + { + panelRank[parent] = i; + } + } + foreach (var (panel, rank) in panelRank) + { + panel.Model.Props.SetText("TabIndex", rank == 0 ? "0" : (100 * rank).ToString()); } Inspector.Rebuild(); } @@ -1648,21 +1677,93 @@ public sealed class DesignerViewModel : ViewModelBase public void GroupSelection() { var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList(); - if (targets.Count < 2) + if (targets.Count < 2 || PageOf(targets[0]) is not { } page) { return; } - Undo.Snapshot(); - var groupId = Guid.NewGuid().ToString("N"); - foreach (var vm in targets) + if (GroupBlockReason(page, targets) is { } reason) { - vm.Model.GroupId = groupId; + DialogService.Notify(DialogKind.Warning, "그룹", reason); + return; + } + + // z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다. + // WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데, + // 사이에 남이 끼어 있으면 그 남이 앞뒤로 밀려 쌓임 순서가 바뀐다(위 가드가 그걸 막는다). + var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList(); + var insertAt = indices.Min(); + + var bounds = targets + .Select(vm => new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height))) + .Aggregate(Rect.Union); + // 좌표는 여기서 정수로 확정한다. 부모 원점과 자식 상대좌표를 따로 반올림하면 + // 저장 시 Location 과 LocationOnBase 가 축당 1px 어긋난 파일이 나온다(포맷이 정수만 쓴다). + var originX = Math.Round(bounds.X); + var originY = Math.Round(bounds.Y); + + Undo.Snapshot(); + var panel = new ControlElement + { + Type = "Panel", + Id = IdGenerator.NextId(document, "Panel"), + Bounds = new LayoutRect + { + X = originX, + Y = originY, + W = Math.Ceiling(bounds.Right - originX), + H = Math.Ceiling(bounds.Bottom - originY), + }, + }; + + foreach (var vm in targets.OrderBy(vm => page.Model.Controls.IndexOf(vm.Model))) + { + page.Model.Controls.Remove(vm.Model); + vm.Model.Bounds.X = Math.Round(vm.X) - originX; + vm.Model.Bounds.Y = Math.Round(vm.Y) - originY; + panel.Children.Add(vm.Model); + } + page.Model.Controls.Insert(Math.Min(insertAt, page.Model.Controls.Count), panel); + + RebuildFromDocument(); + if (PageOf2(page.Index)?.Controls.FirstOrDefault(c => ReferenceEquals(c.Model, panel)) is { } created) + { + Selection.SetSingle(created); } - // 이름이 없으면 목록에 "그룹"만 여러 개 뜬다 — 만들 때 번호를 붙여 준다 - document.GroupNames[groupId] = $"그룹 {ActiveGroupIds().Count}"; - RebuildLayerRows(); } + /// + /// 묶을 수 없는 이유 — 없으면 null. + /// + /// 그룹이 진짜 Panel 이 되면서 레거시의 두 가지 성질이 따라온다. + /// 여기서 막지 않으면 캔버스에는 아무 변화도 안 보이는 채로 EMR 동작이 달라진다. + /// + private string? GroupBlockReason(PageViewModel page, IReadOnlyList targets) + { + // ① 라디오는 부모가 곧 배타 단위다(레거시 RadioButton 에 GroupName 이 없다). + // '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다. + if (targets.Any(vm => vm.Type == "RadioButton")) + { + return "라디오 버튼은 패널이 곧 선택 묶음이라, 일부만 묶으면 '하나만 고르기'가 갈라집니다.\n" + + "라디오는 그룹으로 묶지 마세요."; + } + + // ② 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다. + var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList(); + if (indices.Any(i => i < 0)) + { + return null; + } + var span = indices.Max() - indices.Min() + 1; + if (span != targets.Count) + { + return "묶으려는 것들 사이에 다른 컨트롤이 겹쳐 있어, 묶으면 앞뒤 순서가 바뀝니다.\n" + + "먼저 순서를 붙여 주세요(레이어 목록에서 나란히 오도록)."; + } + return null; + } + + private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null; + /// /// 선택 그룹 해제 — 선택에 걸린 그룹의 전 멤버를 푼다(선택되지 않은 멤버 포함). /// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다. @@ -1674,6 +1775,16 @@ public sealed class DesignerViewModel : ViewModelBase .Where(id => id is not null) .Distinct(StringComparer.Ordinal) .ToList(); + // 그룹은 이제 진짜 패널이다 — 고른 것이 패널이거나 패널 안의 것이면 그 패널을 푼다. + var panel = Selection.Items.OfType().FirstOrDefault(c => c.Parent is null) + ?? Selection.Items.Select(vm => vm.Parent).OfType() + .FirstOrDefault(c => c.Parent is null); + if (panel is not null) + { + UnwrapPanel(panel); + return; + } + if (groupIds.Count == 0) { return; @@ -1693,6 +1804,51 @@ public sealed class DesignerViewModel : ViewModelBase RebuildLayerRows(); } + /// + /// 패널 풀기 — 자식을 최상위로 되돌리고 패널을 지운다. + /// + /// 자식 좌표를 절대값으로 되돌려야 한다. 안 그러면 패널 원점만큼 좌상단으로 튄다. + /// 패널이 있던 z 자리에 자식들을 그대로 끼워 넣어 쌓임 순서를 보존한다. + /// + /// 레거시 디자이너에는 이 조작이 없다 — 거기서 패널을 지우면 자식까지 함께 지워진다. + /// + public void UnwrapPanel(ControlViewModel viewModel) + { + if (viewModel is not ContainerViewModel container + || container.Parent is not null + || PageOf(container) is not { } page) + { + return; + } + var at = page.Model.Controls.IndexOf(container.Model); + if (at < 0) + { + return; + } + + Undo.Snapshot(); + var originX = container.Model.Bounds.X; + var originY = container.Model.Bounds.Y; + var children = container.Model.Children.ToList(); + container.Model.Children.Clear(); + page.Model.Controls.RemoveAt(at); + for (var i = 0; i < children.Count; i++) + { + children[i].Bounds.X = Math.Round(children[i].Bounds.X + originX); + children[i].Bounds.Y = Math.Round(children[i].Bounds.Y + originY); + page.Model.Controls.Insert(at + i, children[i]); + } + + RebuildFromDocument(); + var restored = Pages[page.Index].Controls + .Where(c => children.Any(child => ReferenceEquals(child, c.Model))) + .ToList(); + if (restored.Count > 0) + { + Selection.Set(restored, restored[0]); + } + } + /// 그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록) public List GroupMatesOf(ControlViewModel viewModel) {