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 @@
+
+
@@ -48,7 +54,12 @@
-
+
+
diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
index 92542c9..5134d99 100644
--- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
@@ -2,6 +2,7 @@ using System.Windows;
using System.Windows.Controls;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
+using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Views;
@@ -48,6 +49,12 @@ public partial class PreviewWindow : Window
/// 지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다
private SheetMe.Data.Stores.PatientContext? patient;
+
+ ///
+ /// 이 창에서 눌러 본 체크·라디오 — 문서에는 쓰지 않는다.
+ /// 창이 닫히면 사라지고, 캔버스의 서식은 한 글자도 바뀌지 않는다.
+ ///
+ private readonly SheetMe.Core.Catalog.PreviewCheckState checks = new();
#endregion
#region Constructors
@@ -168,12 +175,15 @@ public partial class PreviewWindow : Window
BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20),
- Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields),
+ Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields, checks),
Width = page.WidthDip,
Height = page.HeightDip,
+ // 어느 종이를 눌렀는지 되찾는 끈 — 클릭 좌표를 페이지 모델로 옮기는 데 쓴다
+ Tag = page,
};
PagesHost.Children.Add(frame);
}
+ ResetChecksButton.IsEnabled = checks.HasAny;
Title = designer.Document.Title.Length > 0
? $"미리보기 — {designer.Document.Title}"
: "미리보기";
@@ -181,6 +191,110 @@ public partial class PreviewWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
+ ///
+ /// 종이 위 체크·라디오를 눌렀다 — 미리보기 안에서만 상태가 바뀐다.
+ ///
+ /// 왜 이 창에서 클릭이 되어야 하나. 레거시 미리보기는 정적 그림이 아니라 런타임 모드
+ /// (TK_PREVIEW)라서 실제로 눌러 보며 확인한다. 라디오가 배타로 묶였는지는
+ /// 눌러 보는 것 말고 확인할 방법이 없다 — 패널로 묶었다고 믿고 저장했다가
+ /// 임상 화면에서 두 개가 동시에 켜지는 것이 이 서식에서 가장 흔한 사고다.
+ ///
+ /// 왜 문서를 안 건드리나. 확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은
+ /// 것만으로 저장할 내용이 생긴다. 그래서 상태는 에만 쌓고 렌더에서 덮는다.
+ ///
+ /// 왜 WPF 히트테스트가 아닌가. 종이 비주얼은 템플릿으로 만든 그림이라
+ /// 컨트롤 하나가 여러 요소(테두리·글자·표시)로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
+ /// 모델 트리를 좌표로 훑으면 캔버스의 HitTestControl 과 같은 규칙을 그대로 쓸 수 있다.
+ ///
+ private void OnPaperClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (Locate(e) is not { } target)
+ {
+ return;
+ }
+ checks.Click(target.Hit.Model, target.Siblings.Select(vm => vm.Model).ToList());
+ BuildPages();
+ }
+
+ /// 누를 수 있는 것 위에서는 손가락 커서 — 안 그러면 클릭이 되는지 알 수 없다
+ private void OnPaperMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
+ => Cursor = Locate(e) is null
+ ? System.Windows.Input.Cursors.Arrow
+ : System.Windows.Input.Cursors.Hand;
+
+ ///
+ /// 마우스 위치의 누를 수 있는 컨트롤과 그 직계 형제들 — 없으면 null.
+ ///
+ /// 형제 목록까지 함께 내는 이유: 라디오 배타는 직계 부모가 가른다
+ /// (레거시 RadioButton 은 GroupName 이 없다) — 누른 것만 알면 무엇을 꺼야 할지 모른다.
+ ///
+ private (ControlViewModel Hit, IReadOnlyList Siblings)? Locate(
+ System.Windows.Input.MouseEventArgs e)
+ {
+ var printFilter = PrintViewToggle.IsChecked == true;
+ foreach (var child in PagesHost.Children)
+ {
+ if (child is not Border { Tag: PageViewModel page, Child: { } paper })
+ {
+ continue;
+ }
+ // 종이(Canvas) 기준 좌표로 받는다 — 테두리 1px 과 줌 배율이 여기서 함께 풀린다
+ var local = e.GetPosition(paper);
+ if (local.X < 0 || local.Y < 0 || local.X > page.WidthDip || local.Y > page.HeightDip)
+ {
+ continue;
+ }
+ return HitInPreview(page.Controls, local, printFilter);
+ }
+ return null;
+ }
+
+ ///
+ /// 그려진 것 중 좌표 위 최상위 컨트롤 — 컨테이너는 자식 우선.
+ ///
+ /// 캔버스의 히트테스트와 두 군데가 다르다.
+ /// ① Locked 를 보지 않는다 — 잠금은 편집 보호일 뿐 런타임에서는 눌린다.
+ /// ② 그려지지 않은 것(숨김·데이터소스·인쇄 제외)은 뺀다 — 안 보이는 것을 누를 수는 없다.
+ ///
+ /// 맨 위가 누를 수 없는 것(라벨 등)이면 그대로 아무 일도 없다 — 아래로 흘리지 않는다.
+ /// WinForms 도 맨 위 컨트롤에만 클릭을 준다.
+ ///
+ private static (ControlViewModel Hit, IReadOnlyList Siblings)? HitInPreview(
+ IList controls, Point local, bool printFilter)
+ {
+ for (var i = controls.Count - 1; i >= 0; i--)
+ {
+ var vm = controls[i];
+ if (vm.Model.Hidden || vm is DataTableViewModel
+ || (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(vm.Model)))
+ {
+ continue;
+ }
+ var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
+ if (!rect.Contains(local))
+ {
+ continue;
+ }
+ if (vm is ContainerViewModel container
+ && HitInPreview(container.Children, new Point(local.X - vm.X, local.Y - vm.Y), printFilter)
+ is { } childHit)
+ {
+ return childHit;
+ }
+ return SheetMe.Core.Catalog.PreviewCheckState.IsClickable(vm.Model)
+ ? (vm, controls.ToList())
+ : null;
+ }
+ return null;
+ }
+
+ /// 눌러 본 것을 전부 원래대로 — 문서는 애초에 안 바뀌었으니 되돌릴 것이 없다
+ private void OnResetChecks(object sender, RoutedEventArgs e)
+ {
+ checks.Clear();
+ BuildPages();
+ }
+
///
/// 실제 환자를 골라 태그에 값을 넣는다.
///
@@ -217,6 +331,10 @@ public partial class PreviewWindow : Window
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
patient = picked;
+ // 눌러 본 흔적은 여기서 지운다. 환자가 바뀌면 태그가 만드는 체크 상태도 바뀌는데,
+ // 손으로 누른 값이 그 위에 남아 있으면 새 환자의 실제 값을 가린다 —
+ // 확인하려고 만든 창이 확인을 막는 셈이다. 서식을 갈아탈 때도 같은 이유로 지운다.
+ checks.Clear();
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
// 가장 민감한 값을 여러 번 꺼내게 된다.
tags = picked is null
@@ -274,7 +392,7 @@ public partial class PreviewWindow : Window
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title
- : designer.Document.FormId, tags, fields);
+ : designer.Document.FormId, tags, fields, checks);
}
catch (Exception ex)
{
diff --git a/tests/SheetMe.Core.Tests/PreviewCheckStateTests.cs b/tests/SheetMe.Core.Tests/PreviewCheckStateTests.cs
new file mode 100644
index 0000000..cf65324
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/PreviewCheckStateTests.cs
@@ -0,0 +1,164 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SheetMe.Core.Catalog;
+using SheetMe.Core.Models;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// 미리보기 클릭 — 라디오 배타가 직계 부모 안에서만 일어나는지가 핵심이다.
+/// 레거시 RadioButton 은 GroupName 이 없어 부모 컨테이너가 문항 단위이고,
+/// 운영 서식의 라디오 98.96% 가 그 형태다.
+///
+[TestClass]
+public class PreviewCheckStateTests
+{
+ #region Helpers
+ private static ControlElement Radio(string id, bool checkedOn = false)
+ {
+ var element = new ControlElement { Id = id, Type = "RadioButton" };
+ element.Props.SetText("Checked", checkedOn ? "True" : "False");
+ return element;
+ }
+
+ private static ControlElement Check(string id, bool checkedOn = false)
+ {
+ var element = new ControlElement { Id = id, Type = "CheckBox" };
+ element.Props.SetText("Checked", checkedOn ? "True" : "False");
+ return element;
+ }
+ #endregion
+
+ #region Tests
+ [TestMethod]
+ public void 누르지_않았으면_서식의_기본값을_그대로_읽는다()
+ {
+ var state = new PreviewCheckState();
+
+ Assert.IsTrue(state.IsChecked(Radio("a", checkedOn: true)));
+ Assert.IsFalse(state.IsChecked(Radio("b")));
+ Assert.IsFalse(state.HasAny);
+ }
+
+ [TestMethod]
+ public void 라디오를_누르면_같은_부모의_다른_라디오가_꺼진다()
+ {
+ var first = Radio("a", checkedOn: true);
+ var second = Radio("b");
+ var third = Radio("c");
+ var siblings = new[] { first, second, third };
+ var state = new PreviewCheckState();
+
+ state.Click(second, siblings);
+
+ Assert.IsFalse(state.IsChecked(first));
+ Assert.IsTrue(state.IsChecked(second));
+ Assert.IsFalse(state.IsChecked(third));
+ }
+
+ [TestMethod]
+ public void 다른_부모의_라디오는_영향을_받지_않는다()
+ {
+ // 패널 1개 = 문항 1개. 옆 문항의 답이 지워지면 그 서식은 못 쓴다.
+ var mine = Radio("a");
+ var neighbour = Radio("z", checkedOn: true);
+ var state = new PreviewCheckState();
+
+ state.Click(mine, new[] { mine, Radio("b") });
+
+ Assert.IsTrue(state.IsChecked(neighbour));
+ }
+
+ [TestMethod]
+ public void 라디오는_다시_눌러도_꺼지지_않는다()
+ {
+ // WinForms 라디오는 자기를 눌러 해제하는 동작이 없다 — 끄는 것은 형제를 누르는 것뿐이다.
+ var radio = Radio("a");
+ var state = new PreviewCheckState();
+
+ state.Click(radio, new[] { radio });
+ state.Click(radio, new[] { radio });
+
+ Assert.IsTrue(state.IsChecked(radio));
+ }
+
+ [TestMethod]
+ public void 체크박스는_누를_때마다_반전한다()
+ {
+ var box = Check("a");
+ var state = new PreviewCheckState();
+
+ state.Click(box, new[] { box });
+ Assert.IsTrue(state.IsChecked(box));
+
+ state.Click(box, new[] { box });
+ Assert.IsFalse(state.IsChecked(box));
+ }
+
+ [TestMethod]
+ public void 체크박스는_형제_라디오를_끄지_않는다()
+ {
+ // 한 패널에 체크박스와 라디오가 섞인 서식이 있다(기타 항목 + 자유 입력).
+ var radio = Radio("r", checkedOn: true);
+ var box = Check("c");
+ var state = new PreviewCheckState();
+
+ state.Click(box, new[] { radio, box });
+
+ Assert.IsTrue(state.IsChecked(radio));
+ Assert.IsTrue(state.IsChecked(box));
+ }
+
+ [TestMethod]
+ public void 기본값이_체크인_체크박스를_누르면_풀린다()
+ {
+ var box = Check("a", checkedOn: true);
+ var state = new PreviewCheckState();
+
+ state.Click(box, new[] { box });
+
+ Assert.IsFalse(state.IsChecked(box));
+ }
+
+ [TestMethod]
+ public void 읽기전용은_눌리지_않는다()
+ {
+ // 레거시는 PreventEditing=True 면 OnClick 을 무시한다 — 값 대입만 가능하다.
+ var locked = Radio("a");
+ locked.Props.SetText("PreventEditing", "True");
+ var mate = Radio("b", checkedOn: true);
+ var state = new PreviewCheckState();
+
+ state.Click(locked, new[] { locked, mate });
+
+ Assert.IsFalse(PreviewCheckState.IsClickable(locked));
+ Assert.IsFalse(state.IsChecked(locked));
+ Assert.IsTrue(state.IsChecked(mate));
+ Assert.IsFalse(state.HasAny);
+ }
+
+ [TestMethod]
+ public void 누를_수_있는_타입은_체크류뿐이다()
+ {
+ Assert.IsTrue(PreviewCheckState.IsClickable(Radio("a")));
+ Assert.IsTrue(PreviewCheckState.IsClickable(Check("b")));
+ Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "c", Type = "Label" }));
+ Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "d", Type = "TextBox" }));
+ Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "e", Type = "Panel" }));
+ }
+
+ [TestMethod]
+ public void 초기화하면_서식의_기본값으로_돌아간다()
+ {
+ var first = Radio("a", checkedOn: true);
+ var second = Radio("b");
+ var state = new PreviewCheckState();
+ state.Click(second, new[] { first, second });
+
+ state.Clear();
+
+ Assert.IsFalse(state.HasAny);
+ Assert.IsTrue(state.IsChecked(first));
+ Assert.IsFalse(state.IsChecked(second));
+ }
+ #endregion
+}