diff --git a/src/SheetMe.Core/Catalog/PreviewCheckState.cs b/src/SheetMe.Core/Catalog/PreviewCheckState.cs new file mode 100644 index 0000000..bfc478f --- /dev/null +++ b/src/SheetMe.Core/Catalog/PreviewCheckState.cs @@ -0,0 +1,77 @@ +using SheetMe.Core.Models; + +namespace SheetMe.Core.Catalog; + +/// +/// 미리보기에서 눌러 본 체크·라디오 상태 — 문서를 건드리지 않는다. +/// +/// 왜 별도 상태인가. 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라 +/// 실제로 눌러 보며 확인한다. 우리 미리보기도 눌러 볼 수 있어야 하지만, 그 클릭이 디자인을 +/// 바꾸면 "확인하려고 눌렀는데 서식이 수정됨" 이 된다. 그래서 컨트롤 Id → 상태 사전으로만 들고 +/// 렌더 시점에 덮어쓴다. 미리보기를 닫으면 사라진다. +/// +/// 배타 규칙은 직계 부모다. 레거시 RadioButton 은 WinForms 를 그대로 상속해 +/// GroupName 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다. +/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식이다 — +/// 즉 패널 하나가 문항 하나다. 여기서도 형제는 직계 부모의 자식만 본다. +/// +public sealed class PreviewCheckState +{ + #region Member Fields + private readonly Dictionary overrides = new(StringComparer.Ordinal); + #endregion + + #region Properties + /// 눌러 본 것이 하나라도 있는가 — 렌더가 덮어쓸 필요가 있는지 판단용 + public bool HasAny => overrides.Count > 0; + + /// 덮어쓸 상태 — 눌러 본 적 없으면 null + public IReadOnlyDictionary Overrides => overrides; + #endregion + + #region Methods + /// 이 컨트롤이 지금 체크된 상태인가 — 눌러 본 값이 없으면 서식의 기본값 + public bool IsChecked(ControlElement element) + => overrides.TryGetValue(element.Id, out var state) + ? state + : string.Equals(element.Props.GetText("Checked"), "True", StringComparison.OrdinalIgnoreCase); + + /// 미리보기에서 누를 수 있는 타입인가 + public static bool IsClickable(ControlElement element) + => element.Type is "CheckBox" or "RadioButton" + // 레거시는 읽기전용(PreventEditing)이면 OnClick 자체를 무시한다 + // (RadioButton.vb:271-279) — 값 대입은 되지만 클릭은 안 먹는다. + && !string.Equals(element.Props.GetText("PreventEditing"), "True", StringComparison.OrdinalIgnoreCase); + + /// + /// 눌렀다 — 체크박스는 반전, 라디오는 자기를 켜고 직계 형제 라디오를 끈다. + /// + /// 누른 컨트롤 + /// 그 컨트롤의 직계 부모가 담은 컨트롤 전부(자기 포함) + public void Click(ControlElement target, IReadOnlyList siblings) + { + if (!IsClickable(target)) + { + return; + } + if (target.Type == "CheckBox") + { + overrides[target.Id] = !IsChecked(target); + return; + } + // 라디오 — 이미 켜져 있어도 다시 켠 상태로 둔다(레거시도 체크를 풀지 않는다). + // 끄는 것은 같은 부모의 다른 라디오를 누르는 것뿐이다. + foreach (var sibling in siblings) + { + if (sibling.Type == "RadioButton" && !ReferenceEquals(sibling, target)) + { + overrides[sibling.Id] = false; + } + } + overrides[target.Id] = true; + } + + /// 눌러 본 것을 전부 잊는다 — 환자를 바꾸거나 서식을 다시 열 때 + public void Clear() => overrides.Clear(); + #endregion +} diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 2ff7884..e095264 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -523,6 +523,88 @@ public static class EditSmoke && !withResolver.Contains("[", StringComparison.Ordinal), withResolver); + // ── 미리보기에서 눌러 보기 ── + // 레거시 미리보기는 정적 그림이 아니라 런타임 모드(TK_PREVIEW)라서 눌러 보며 확인한다. + // 라디오가 배타로 묶였는지는 눌러 보는 것 말고 확인할 방법이 없다 — + // 패널로 묶었다고 믿고 저장했다가 임상 화면에서 두 개가 동시에 켜지는 것이 가장 흔한 사고다. + // 여기서 지키는 두 가지: ① 형제 배타가 종이 렌더까지 도달한다 ② 문서는 안 바뀐다. + { + var clickDoc = new FormDocument(); + var clickPage = new FormPage { Width = 720, Height = 856 }; + clickDoc.Pages.Add(clickPage); + var panelModel = new ControlElement + { + Type = "Panel", Id = "문항", + Bounds = new LayoutRect { X = 40, Y = 40, W = 200, H = 100 }, + }; + for (var i = 0; i < 2; i++) + { + var option = new ControlElement + { + Type = "RadioButton", Id = $"보기{i}", + Bounds = new LayoutRect { X = 10, Y = 10 + (i * 30), W = 120, H = 20 }, + }; + // 첫 번째가 기본 체크 — 눌렀을 때 꺼지는지까지 봐야 한다 + option.Props.SetText("Checked", i == 0 ? "True" : "False"); + panelModel.Children.Add(option); + } + clickPage.Controls.Add(panelModel); + var clickVm = new DesignerViewModel(clickDoc); + var clickPanel = clickVm.Pages[0].Controls.OfType().First(); + // Id 로 집는다 — 자식 VM 은 문서 순서가 아니라 z 순서로 온다. + // 처음에 Children[0]/[1] 로 집었다가 둘이 뒤바뀌어, 이미 켜진 라디오를 누르는 + // 시험이 되어 버렸다(그리고 "문서가 바뀌었다"는 거짓 실패가 났다). + var first = clickPanel.Children.Single(c => c.Model.Id == "보기0"); + var second = clickPanel.Children.Single(c => c.Model.Id == "보기1"); + var state = new SheetMe.Core.Catalog.PreviewCheckState(); + + // 종이에 실제로 그려지는 값을 읽는다 — 상태 객체만 보면 렌더까지 오는지 알 수 없다. + // 컨테이너는 인쇄용 사본으로 투영되므로 사본의 자식에서 읽어야 한다. + string PaperChecked(string id) + { + var canvas = (System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual( + clickVm.Pages[0], true, null, null, state); + foreach (var child in canvas.Children) + { + if (child is not System.Windows.Controls.ContentPresenter + { Content: ContainerViewModel projected }) + { + continue; + } + var found = projected.Children.FirstOrDefault(c => c.Model.Id == id); + if (found is not null) + { + return found.Model.Props.GetText("Checked") ?? "(없음)"; + } + } + return "(못 찾음)"; + } + + Check("미리보기 클릭: 누르기 전에는 서식의 기본 상태", + PaperChecked("보기0") == "True" && PaperChecked("보기1") == "False", + $"보기0={PaperChecked("보기0")} 보기1={PaperChecked("보기1")}"); + + state.Click(second.Model, clickPanel.Children.Select(c => c.Model).ToList()); + Check("미리보기 클릭: 누른 라디오가 종이에서 켜진다", PaperChecked("보기1") == "True", + PaperChecked("보기1")); + Check("미리보기 클릭: 같은 패널의 형제가 꺼진다", PaperChecked("보기0") == "False", + PaperChecked("보기0")); + + // 이것이 이 기능의 안전선이다 — 확인하려고 누른 것이 서식 수정이 되면 + // 미리보기를 열었다 닫은 것만으로 저장할 내용이 생긴다. + Check("미리보기 클릭: 문서 모델은 한 글자도 안 바뀐다", + first.Model.Props.GetText("Checked") == "True" + && second.Model.Props.GetText("Checked") == "False" + && !clickVm.Undo.IsDirty, + $"모델 보기0={first.Model.Props.GetText("Checked")} " + + $"보기1={second.Model.Props.GetText("Checked")} dirty={clickVm.Undo.IsDirty}"); + + state.Clear(); + Check("미리보기 클릭: 초기화하면 기본 상태로 돌아온다", + PaperChecked("보기0") == "True" && PaperChecked("보기1") == "False", + $"보기0={PaperChecked("보기0")} 보기1={PaperChecked("보기1")}"); + } + // ── 환자 문맥을 붙였을 때 ── // 여기가 이 단계의 전부다. 환자를 골랐는데 종이가 그대로면 고른 의미가 없고, // 그건 환자 선택이 아예 없는 것보다 나쁘다("되는 줄 알았는데 안 된다"). @@ -1572,6 +1654,7 @@ public static class EditSmoke var box = designer.WorldBoundsOf(dragTarget); var grab = new Point(box.X + box.Width / 2, box.Y + box.Height / 2); designer.Selection.Clear(); + var countBefore = designer.Pages[0].Controls.Count; var survived = true; try { @@ -1588,6 +1671,14 @@ public static class EditSmoke { Check("빈 선택 Ctrl+드래그: 예외 없음", true); } + // Ctrl+드래그는 복사다 — 이 시험이 남긴 사본을 되돌려야 뒤따르는 Find/개수 검사가 흔들리지 않는다 + if (designer.Pages[0].Controls.Count > countBefore) + { + designer.Undo.Undo(); + } + Check("빈 선택 Ctrl+드래그: 사본을 남기지 않는다", + designer.Pages[0].Controls.Count == countBefore, + $"{designer.Pages[0].Controls.Count} (기대 {countBefore})"); designer.Selection.Clear(); } @@ -1634,7 +1725,12 @@ public static class EditSmoke designer.DeleteSelection(); } - // 20-5f) 선택 밖 컨트롤을 Ctrl+드래그하면 그것이 끌려야 한다(기존 선택은 유지) + // 20-5f) Ctrl+드래그 = 복사. 원본은 자리에 남고 사본이 끌린다(디자이너 관례). + // + // 예전에는 이 제스처가 '선택에 더하고 이동'이었다 — Ctrl 이 선택 추가 수정자였기 때문이다. + // 복사 쪽이 이기는 이유: 같은 컨트롤을 여러 개 놓는 일이 서식 작업의 대부분인데 + // (같은 문항의 라디오 5개, 같은 서식의 칸 20개) 그 방법이 복사·붙여넣기뿐이었고, + // 붙여넣기는 항상 최상위 +12,+12 에 떨어져 위치를 다시 잡아야 했다. { var anchor = Find("CheckBox"); var grabbed = Find("Label"); @@ -1643,19 +1739,113 @@ public static class EditSmoke var box = designer.WorldBoundsOf(grabbed); var from = new Point(box.X + box.Width / 2, box.Y + box.Height / 2); var grabbedX = grabbed.X; + var countBefore = designer.Pages[0].Controls.Count; // Alt 로 정렬 스냅을 끈다 — 스냅이 걸리면 이동량이 가이드에 붙어 델타 단언이 흔들린다 var ctrlAlt = new PointerContext(true, false, true, 1); designer.Interaction.PointerDown(from, ctrlAlt); designer.Interaction.PointerMove(new Point(from.X + 30, from.Y), ctrlAlt); designer.Interaction.PointerUp(new Point(from.X + 30, from.Y), ctrlAlt); - Check("Ctrl+드래그: 잡은 컨트롤이 움직인다", Math.Abs(grabbed.X - (grabbedX + 30)) < 2, + Check("Ctrl+드래그: 컨트롤이 하나 늘어난다", designer.Pages[0].Controls.Count == countBefore + 1, + $"{countBefore} → {designer.Pages[0].Controls.Count}"); + Check("Ctrl+드래그: 원본은 제자리에 남는다", Math.Abs(grabbed.X - grabbedX) < 0.5, $"{grabbedX} → {grabbed.X}"); - Check("Ctrl+드래그: 기존 선택도 함께 움직인다(합집합)", Math.Abs(anchor.X - (anchorAt.X + 30)) < 2, + var copy = designer.Selection.Items.SingleOrDefault(); + Check("Ctrl+드래그: 끌린 것은 사본이다(선택도 사본)", + copy is not null && !ReferenceEquals(copy, grabbed) && copy.Type == grabbed.Type, + copy is null ? "선택 없음" : $"{copy.Id} vs 원본 {grabbed.Id}"); + Check("Ctrl+드래그: 사본이 끌린 만큼 움직인다", + copy is not null && Math.Abs(copy.X - (grabbedX + 30)) < 2, + copy is null ? "사본 없음" : $"{copy.X} (기대 {grabbedX + 30})"); + // Ctrl 은 이 제스처에서 '복사'이므로 선택 추가가 아니다 — 아까 고른 것은 복제되지 않는다 + Check("Ctrl+드래그: 기존 선택은 복제되지도 움직이지도 않는다", + Math.Abs(anchor.X - anchorAt.X) < 0.5 + && designer.Pages[0].Controls.Count(c => c.Type == "CheckBox") == 1, $"{anchorAt.X} → {anchor.X}"); designer.Undo.Undo(); + Check("Ctrl+드래그: Undo 한 번으로 사본이 사라진다", + designer.Pages[0].Controls.Count == countBefore, + $"{designer.Pages[0].Controls.Count} (기대 {countBefore})"); designer.Selection.Clear(); } + // 20-5f2) 패널 안의 것을 Ctrl+드래그하면 사본이 같은 패널 안에 생겨야 한다. + // + // 레거시 라디오는 GroupName 이 없어 직계 부모가 배타 그룹이다(운영 라디오 98.96%가 + // 패널 직계 자식). 사본이 패널 밖으로 나가면 같은 문항의 답이 두 개가 되고, + // 그 서식은 임상 화면에서 라디오 두 개가 동시에 켜진다 — 눈으로는 안 보이는 고장이다. + { + var nest = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + nest.AddControlAt("RadioButton", new Point(100, 100)); + nest.AddControlAt("RadioButton", new Point(100, 140)); + nest.Selection.Set(nest.Pages[0].Controls.ToList(), nest.Pages[0].Controls[0]); + nest.GroupSelection(); + var nestPanel = nest.Pages[0].Controls.OfType().FirstOrDefault(c => c.Type == "Panel"); + var radio = nestPanel?.Children.FirstOrDefault(c => c.Type == "RadioButton"); + if (nestPanel is null || radio is null) + { + Check("Ctrl+드래그(패널 안): 라디오를 패널로 묶지 못했다", false); + } + else + { + var world = nest.WorldBoundsOf(radio); + var grab = new Point(world.X + world.Width / 2, world.Y + world.Height / 2); + var childrenBefore = nestPanel.Children.Count; + var topBefore = nest.Pages[0].Controls.Count; + var ctrlAlt = new PointerContext(true, false, true, 1); + + // ① 그냥 자식을 집으면 패널이 잡힌다(선택 단위 = 패널) — 문항 하나를 통째로 복사한다. + // 이게 기본이어야 하는 이유: 같은 문항을 여러 개 만드는 일이 라디오 작업의 대부분이다. + nest.Interaction.PointerDown(grab, ctrlAlt); + nest.Interaction.PointerMove(new Point(grab.X, grab.Y + 60), ctrlAlt); + nest.Interaction.PointerUp(new Point(grab.X, grab.Y + 60), ctrlAlt); + var panels = nest.Pages[0].Controls.OfType().ToList(); + Check("Ctrl+드래그(패널): 문항이 통째로 복사된다", + nest.Pages[0].Controls.Count == topBefore + 1 && panels.Count == 2, + $"최상위 {topBefore} → {nest.Pages[0].Controls.Count}, 패널 {panels.Count}개"); + Check("Ctrl+드래그(패널): 사본도 라디오를 그대로 담는다", + panels.Count == 2 && panels.All(p => p.Children.Count == childrenBefore + && p.Children.All(c => c.Type == "RadioButton")), + string.Join(" ", panels.Select(p => $"자식{p.Children.Count}"))); + // 사본의 라디오는 새 Id 여야 한다 — 같은 Id 가 둘이면 저장·값대입이 서로를 덮는다 + var ids = panels.SelectMany(p => p.Children).Select(c => c.Id).ToList(); + Check("Ctrl+드래그(패널): 사본 안 라디오 Id 가 겹치지 않는다", + ids.Distinct(StringComparer.Ordinal).Count() == ids.Count, + string.Join(",", ids)); + nest.Undo.Undo(); + + // ② 안으로 들어간 뒤(더블클릭/Enter) 자식을 Ctrl+드래그하면 그 자식만 복사되고 + // 사본은 같은 패널에 남는다 — 문항에 선택지를 하나 더 붙이는 동작이다. + // 사본이 패널 밖으로 나가면 배타 그룹이 갈라져 라디오 두 개가 동시에 켜진다. + var panelAfterUndo = nest.Pages[0].Controls.OfType().First(); + nest.Selection.SetSingle(panelAfterUndo); + nest.EnterSelection(); + var innerChild = nest.Selection.Primary; + if (innerChild is null || innerChild.Parent is null) + { + Check("Ctrl+드래그(패널 안): 컨테이너로 들어가지 못했다", false); + } + else + { + var childBox = nest.WorldBoundsOf(innerChild); + var innerGrab = new Point(childBox.X + childBox.Width / 2, childBox.Y + childBox.Height / 2); + var innerBefore = panelAfterUndo.Children.Count; + var innerTop = nest.Pages[0].Controls.Count; + nest.Interaction.PointerDown(innerGrab, ctrlAlt); + nest.Interaction.PointerMove(new Point(innerGrab.X + 20, innerGrab.Y), ctrlAlt); + nest.Interaction.PointerUp(new Point(innerGrab.X + 20, innerGrab.Y), ctrlAlt); + Check("Ctrl+드래그(패널 안): 사본이 같은 패널 자식이 된다", + panelAfterUndo.Children.Count == innerBefore + 1, + $"{innerBefore} → {panelAfterUndo.Children.Count}"); + Check("Ctrl+드래그(패널 안): 최상위로 튀어나오지 않는다", + nest.Pages[0].Controls.Count == innerTop, + $"{innerTop} → {nest.Pages[0].Controls.Count}"); + Check("Ctrl+드래그(패널 안): 모델과 VM 의 자식 수가 맞는다", + panelAfterUndo.Model.Children.Count == panelAfterUndo.Children.Count, + $"모델 {panelAfterUndo.Model.Children.Count} vs VM {panelAfterUndo.Children.Count}"); + } + } + } + // 20-5g) 데이터소스 배선 — 전용 행이 붙고, 이 서식의 데이터소스만 후보가 된다 { designer.AddControlAt("DataTable", new Point(600, 320)); diff --git a/src/SheetMe.Designer/Services/InteractionController.cs b/src/SheetMe.Designer/Services/InteractionController.cs index c8a0637..023403e 100644 --- a/src/SheetMe.Designer/Services/InteractionController.cs +++ b/src/SheetMe.Designer/Services/InteractionController.cs @@ -156,8 +156,28 @@ public sealed class InteractionController if (!downControl.IsSelected) { var mates = designer.GroupMatesOf(downControl); - designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl); + // Shift 는 이 제스처에서도 '더하기'다 — 기존 선택과 합친다(업 토글과 같은 규칙). + // Ctrl 은 '복사'라서 더하기가 아니다: 합집합으로 두면 아까 선택해 둔 컨트롤까지 + // 같이 복제돼, 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다. + designer.Selection.Set( + ctx.Ctrl ? mates : designer.Selection.Items.Union(mates).ToList(), downControl); } + // Ctrl+드래그 = 복사(디자이너 관례). 원본은 자리에 남고 복제본이 끌린다. + // + // 왜 여기서 복제하나. 누름 시점에 하면 Ctrl+클릭(선택 토글)만으로도 사본이 생긴다. + // 드래그 문턱을 넘은 이 지점이 "복사할 뜻"이 확정되는 유일한 자리다. + // + // 왜 붙여넣기가 아닌가. Paste 는 항상 최상위 +12,+12 에 놓는다 — 패널 안 라디오를 + // 그렇게 복사하면 사본이 패널 밖으로 나가 배타 그룹이 깨진다(레거시 라디오는 + // 직계 부모가 그룹이다). 제자리 복제는 부모를 유지하므로 같은 문항 안에 남는다. + if (ctx.Ctrl && designer.DuplicateSelectionInPlace()) + { + // 복제가 이미 스냅샷을 찍었다 — 이어지는 이동은 추가로 찍지 않는다. + // 그러면 "복사 후 이동" 이 Undo 두 번이 되고, 한 번만 누른 사용자는 + // 원본 자리에 사본이 겹쳐 남은 상태를 보게 된다. + undoCaptured = true; + } + // 복제가 거절되면(표 등) 그냥 이동한다 — 선택은 원본에 남아 있다 BeginMove(); DoMove(world, ctx); // 전환 프레임부터 즉시 추종 } diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs index 1e87809..9aad912 100644 --- a/src/SheetMe.Designer/Services/PrintService.cs +++ b/src/SheetMe.Designer/Services/PrintService.cs @@ -18,7 +18,8 @@ public static class PrintService /// 인쇄 대화상자 → 전체 페이지 인쇄 public static void Print(DesignerViewModel designer, string documentName, SheetMe.Core.Catalog.ITagValueResolver? tags = null, - SheetMe.Core.Catalog.IDataFieldResolver? fields = null) + SheetMe.Core.Catalog.IDataFieldResolver? fields = null, + SheetMe.Core.Catalog.PreviewCheckState? checks = null) { var dialog = new System.Windows.Controls.PrintDialog(); // 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한 @@ -38,7 +39,7 @@ public static class PrintService return; } - var document = BuildFixedDocument(designer, tags, fields); + var document = BuildFixedDocument(designer, tags, fields, checks); dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}"); } @@ -53,7 +54,8 @@ public static class PrintService /// public static FixedDocument BuildFixedDocument(DesignerViewModel designer, SheetMe.Core.Catalog.ITagValueResolver? tags = null, - SheetMe.Core.Catalog.IDataFieldResolver? fields = null) + SheetMe.Core.Catalog.IDataFieldResolver? fields = null, + SheetMe.Core.Catalog.PreviewCheckState? checks = null) { var document = new FixedDocument(); foreach (var page in designer.Pages) @@ -66,7 +68,7 @@ public static class PrintService }; // 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) — // 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다. - fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields)); + fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields, checks)); var pageContent = new PageContent(); ((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage); @@ -86,7 +88,8 @@ public static class PrintService /// public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true, SheetMe.Core.Catalog.ITagValueResolver? tags = null, - SheetMe.Core.Catalog.IDataFieldResolver? fields = null) + SheetMe.Core.Catalog.IDataFieldResolver? fields = null, + SheetMe.Core.Catalog.PreviewCheckState? checks = null) { var canvas = new Canvas { @@ -118,7 +121,7 @@ public static class PrintService } var presenter = new ContentPresenter { - Content = PrintProjection(control, printFilter, tags, fields), + Content = PrintProjection(control, printFilter, tags, fields, checks), Width = Math.Max(1, control.Width), Height = Math.Max(1, control.Height), }; @@ -149,11 +152,12 @@ public static class PrintService /// private static object PrintProjection(ControlViewModel control, bool printFilter, SheetMe.Core.Catalog.ITagValueResolver? tags, - SheetMe.Core.Catalog.IDataFieldResolver? fields) + SheetMe.Core.Catalog.IDataFieldResolver? fields, + SheetMe.Core.Catalog.PreviewCheckState? checks = null) { if (control is not ContainerViewModel container) { - return WithTagValue(control, tags, fields); + return WithTagValue(control, tags, fields, checks); } ContainerViewModel? copy = container switch { @@ -173,7 +177,7 @@ public static class PrintService { continue; } - if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected) + if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected) { copy.Children.Add(projected); } @@ -201,22 +205,31 @@ public static class PrintService /// 진단(--db-patient·edit-smoke)이 그것으로 판정한다. /// private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags, - SheetMe.Core.Catalog.IDataFieldResolver? fields) + SheetMe.Core.Catalog.IDataFieldResolver? fields, + SheetMe.Core.Catalog.PreviewCheckState? checks = null) { + // 미리보기에서 눌러 본 체크·라디오는 무엇보다 뒤다 — 태그가 만든 체크 상태까지 덮는다. + // 그 칸이 어떻게 보이는지 직접 눌러 확인하는 중이므로 사용자의 손이 마지막이다. + bool? clicked = null; + if (control is CheckBoxViewModel or RadioButtonViewModel && checks is not null + && checks.Overrides.TryGetValue(control.Model.Id, out var state)) + { + clicked = state; + } // 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고, // 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 정해져 있어야 하기 때문이다 // (정하지 않으면 같은 서식이 실행마다 다르게 보인다). var (label, value) = Bound(control, tags, fields); if (label is null) { - return control; + return clicked is null ? control : Rechecked(control, clicked.Value); } // 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고 // ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면 // "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다. if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel) { - return control; + return clicked is null ? control : Rechecked(control, clicked.Value); } // 이미지류(싸인·직인·로고)의 값은 파일 경로다 — Text 가 아니라 경로 속성에 넣어 // 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다). @@ -239,7 +252,7 @@ public static class PrintService // 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString // "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다. var upper = value.Text.ToUpperInvariant(); - model.Props.SetText("Checked", upper is "Y" or "TRUE" ? "True" : "False"); + model.Props.SetText("Checked", (clicked ?? upper is "Y" or "TRUE") ? "True" : "False"); } else { @@ -249,6 +262,14 @@ public static class PrintService return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground); } + /// 미리보기에서 눌러 본 체크 상태만 갈아 끼운 사본 — 원본 VM·모델은 그대로다 + private static object Rechecked(ControlViewModel control, bool state) + { + var model = control.Model.Clone(); + model.Props.SetText("Checked", state ? "True" : "False"); + return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground); + } + /// /// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다. /// diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index c92bcb5..8af438a 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -1413,6 +1413,71 @@ public sealed class DesignerViewModel : ViewModelBase RebuildLayerRows(); } + /// + /// 선택분을 제자리에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다. + /// + /// 붙여넣기와 다른 점: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너) + /// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 같은 패널 안에 남아 + /// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다. + /// + /// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로 + /// 복제본에는 격자가 없다 — 와 같은 이유). + /// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다. + /// + public bool DuplicateSelectionInPlace() + { + var sources = Selection.Items.ToList(); + if (sources.Count == 0) + { + return false; + } + var (pastable, _) = FilterPastable(sources.Select(vm => vm.Model).ToList()); + if (pastable.Count == 0) + { + return false; + } + Undo.Snapshot(); + var usedIds = IdGenerator.CollectUsed(document); + var groupRemap = new Dictionary(StringComparer.Ordinal); + var copies = new List(); + foreach (var vm in sources) + { + if (!pastable.Contains(vm.Model)) + { + continue; + } + var page = PageOf(vm); + if (page is null) + { + continue; + } + var clone = vm.Model.Clone(); + AssignNewIds(clone, usedIds); + RemapGroupId(clone, groupRemap); + if (vm.Parent is ContainerViewModel parent) + { + // 컨테이너 자식 — 같은 부모 안, 같은 상대좌표에 둔다 + parent.Model.Children.Insert(0, clone); + var childVm = DocumentMapper.CreateControl(clone, parent.EffectiveFont, parent.Foreground); + childVm.Parent = parent; + parent.Children.Add(childVm); + copies.Add(childVm); + } + else + { + page.Model.Controls.Insert(0, clone); + copies.Add(CreateAndAttach(clone, page, attachToEnd: true)); + } + } + if (copies.Count == 0) + { + return false; + } + Selection.Set(copies, copies.LastOrDefault()); + RebuildLayerRows(); + return true; + } + /// 복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다 private void RemapGroupId(ControlElement element, Dictionary remap) { diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml b/src/SheetMe.Designer/Views/PreviewWindow.xaml index ccd5274..c365ed6 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml @@ -40,6 +40,12 @@ + +