미리보기를 눌러 볼 수 있게 하고, Ctrl+드래그를 복사로 바꾼다

사용자 지적 두 건. 둘 다 라디오 배타 그룹이 걸린 문제였다.

## 미리보기 클릭이 안 먹었다

레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라 눌러 보며 확인한다.
우리 미리보기는 그림이었다 — 그래서 <b>라디오가 배타로 묶였는지 확인할 방법이 없었다</b>.
패널로 묶었다고 믿고 저장했다가 임상 화면에서 두 개가 동시에 켜지는 것이
이 서식에서 가장 흔한 사고인데, 저장 전에 그걸 볼 수단이 없었던 것이다.

PreviewCheckState — 컨트롤 Id → 체크 상태 사전. 문서는 건드리지 않는다.
확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은 것만으로
저장할 내용이 생긴다. 렌더 시점(WithTagValue)에만 덮고, 창을 닫으면 사라진다.

배타는 직계 부모 안에서만. 레거시 RadioButton 은 WinForms 를 그대로 상속해
GroupName 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식 — 패널 1개 = 문항 1개다.

- PreventEditing=True 는 안 눌린다(RadioButton.vb:271-279 — 값 대입만 되고 클릭은 무시).
  Locked 는 편집 보호일 뿐이라 눌린다.
- 라디오는 다시 눌러도 안 꺼진다. 끄는 것은 형제를 누르는 것뿐이다.
- 손으로 누른 값이 태그 값을 이긴다 — 그 칸을 직접 눌러 보는 중이므로 사용자의 손이 마지막이다.
- 환자를 바꾸거나 서식을 갈아타면 지운다. 안 지우면 손으로 누른 값이
  새 환자의 실제 값을 가려, 확인하려고 만든 창이 확인을 막는다.
- 이 창에서 인쇄하면 화면과 같은 상태로 나간다(같은 checks 를 Print 까지 넘긴다).

히트테스트는 WPF 가 아니라 모델 트리로 한다. 종이 비주얼은 템플릿이 만든 그림이라
컨트롤 하나가 여러 요소로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
캔버스의 HitTestControl 과 두 군데만 다르다 — Locked 를 보지 않고,
그려지지 않은 것(숨김·데이터소스·인쇄 제외)을 뺀다.

## Ctrl+드래그가 복사가 아니었다

예전에는 '선택에 더하고 이동'이었다(Ctrl 이 선택 추가 수정자였으니까).
그래서 같은 컨트롤을 여러 개 놓는 방법이 복사·붙여넣기뿐이었는데,
붙여넣기는 <b>항상 최상위 +12,+12</b> 에 떨어진다.
패널 안 라디오를 그렇게 복사하면 사본이 패널 밖으로 나가 배타 그룹이 깨진다 —
눈으로는 안 보이고 임상 화면에서만 드러나는 고장이다.

DuplicateSelectionInPlace — 제자리 복제. 부모를 유지하므로 사본이 같은 문항 안에 남는다.
드래그 문턱을 넘은 지점에서 복제한다(누름 시점에 하면 Ctrl+클릭만으로도 사본이 생긴다).
복제가 스냅샷을 이미 찍었으므로 이어지는 이동은 추가로 찍지 않는다 —
안 그러면 "복사 후 이동"이 Undo 두 번이 되고, 한 번만 누른 사용자는
원본 자리에 사본이 겹쳐 남은 상태를 본다.

Ctrl 은 이 제스처에서 더하기가 아니다. 합집합으로 두면 아까 선택해 둔 컨트롤까지
함께 복제돼 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다. Shift 는 그대로 더하기다.

선택 단위가 패널이므로 자식을 그냥 집으면 <b>문항이 통째로</b> 복사되고,
더블클릭으로 들어간 뒤 집으면 <b>그 선택지만</b> 복사돼 같은 패널에 남는다.
둘 다 필요한 동작이라 둘 다 시험한다.

## 판정

- 단위 시험 368 (PreviewCheckState 10건 추가)
- edit-smoke 320건 전건 통과 (Ctrl+드래그 복사 9건, 미리보기 클릭 5건 추가)
  · 20-5f 의 옛 단언 2건은 의미가 바뀌어 교체했다("합집합으로 함께 움직인다" → "복사된다")
  · 20-5c(빈 선택 Ctrl+드래그)가 남기는 사본을 되돌린다 — 안 하면 뒤따르는 개수 검사가 흔들린다
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" 39건 · --dialog-shots · --cleartype · --db-filecfg 전건 통과

시험을 처음 쓸 때 자식을 Children[0]/[1] 로 집었는데 자식 VM 은 z 순서로 온다.
이미 켜진 라디오를 누르는 시험이 되어 "문서가 바뀌었다"는 거짓 실패가 났다 — Id 로 집는다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 15:07:51 +09:00
co-authored by Claude Fable 5
parent d965b3f217
commit 8cc2d68ac1
8 changed files with 686 additions and 20 deletions
@@ -0,0 +1,77 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 미리보기에서 눌러 본 체크·라디오 상태 — <b>문서를 건드리지 않는다</b>.
///
/// <b>왜 별도 상태인가.</b> 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
/// 실제로 눌러 보며 확인한다. 우리 미리보기도 눌러 볼 수 있어야 하지만, 그 클릭이 디자인을
/// 바꾸면 "확인하려고 눌렀는데 서식이 수정됨" 이 된다. 그래서 컨트롤 Id → 상태 사전으로만 들고
/// 렌더 시점에 덮어쓴다. 미리보기를 닫으면 사라진다.
///
/// <b>배타 규칙은 직계 부모다.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
/// <c>GroupName</c> 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식이다 —
/// 즉 <b>패널 하나가 문항 하나</b>다. 여기서도 형제는 직계 부모의 자식만 본다.
/// </summary>
public sealed class PreviewCheckState
{
#region Member Fields
private readonly Dictionary<string, bool> overrides = new(StringComparer.Ordinal);
#endregion
#region Properties
/// <summary>눌러 본 것이 하나라도 있는가 — 렌더가 덮어쓸 필요가 있는지 판단용</summary>
public bool HasAny => overrides.Count > 0;
/// <summary>덮어쓸 상태 — 눌러 본 적 없으면 null</summary>
public IReadOnlyDictionary<string, bool> Overrides => overrides;
#endregion
#region Methods
/// <summary>이 컨트롤이 지금 체크된 상태인가 — 눌러 본 값이 없으면 서식의 기본값</summary>
public bool IsChecked(ControlElement element)
=> overrides.TryGetValue(element.Id, out var state)
? state
: string.Equals(element.Props.GetText("Checked"), "True", StringComparison.OrdinalIgnoreCase);
/// <summary>미리보기에서 누를 수 있는 타입인가</summary>
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);
/// <summary>
/// 눌렀다 — 체크박스는 반전, 라디오는 자기를 켜고 <b>직계 형제 라디오를 끈다</b>.
/// </summary>
/// <param name="target">누른 컨트롤</param>
/// <param name="siblings">그 컨트롤의 <b>직계 부모</b>가 담은 컨트롤 전부(자기 포함)</param>
public void Click(ControlElement target, IReadOnlyList<ControlElement> 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;
}
/// <summary>눌러 본 것을 전부 잊는다 — 환자를 바꾸거나 서식을 다시 열 때</summary>
public void Clear() => overrides.Clear();
#endregion
}
+193 -3
View File
@@ -523,6 +523,88 @@ public static class EditSmoke
&& !withResolver.Contains("[", StringComparison.Ordinal), && !withResolver.Contains("[", StringComparison.Ordinal),
withResolver); withResolver);
// ── 미리보기에서 눌러 보기 ──
// 레거시 미리보기는 정적 그림이 아니라 런타임 모드(TK_PREVIEW)라서 눌러 보며 확인한다.
// 라디오가 배타로 묶였는지는 <b>눌러 보는 것 말고 확인할 방법이 없다</b> —
// 패널로 묶었다고 믿고 저장했다가 임상 화면에서 두 개가 동시에 켜지는 것이 가장 흔한 사고다.
// 여기서 지키는 두 가지: ① 형제 배타가 종이 렌더까지 도달한다 ② 문서는 안 바뀐다.
{
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 },
};
// 첫 번째가 기본 체크 — 눌렀을 때 <b>꺼지는지</b>까지 봐야 한다
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<ContainerViewModel>().First();
// Id 로 집는다 — 자식 VM 은 문서 순서가 아니라 z 순서로 온다.
// 처음에 Children[0]/[1] 로 집었다가 둘이 뒤바뀌어, 이미 켜진 라디오를 누르는
// 시험이 되어 버렸다(그리고 "문서가 바뀌었다"는 <b>거짓 실패</b>가 났다).
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 box = designer.WorldBoundsOf(dragTarget);
var grab = new Point(box.X + box.Width / 2, box.Y + box.Height / 2); var grab = new Point(box.X + box.Width / 2, box.Y + box.Height / 2);
designer.Selection.Clear(); designer.Selection.Clear();
var countBefore = designer.Pages[0].Controls.Count;
var survived = true; var survived = true;
try try
{ {
@@ -1588,6 +1671,14 @@ public static class EditSmoke
{ {
Check("빈 선택 Ctrl+드래그: 예외 없음", true); 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(); designer.Selection.Clear();
} }
@@ -1634,7 +1725,12 @@ public static class EditSmoke
designer.DeleteSelection(); designer.DeleteSelection();
} }
// 20-5f) 선택 밖 컨트롤을 Ctrl+드래그하면 그것이 끌려야 한다(기존 선택은 유지) // 20-5f) Ctrl+드래그 = 복사. 원본은 자리에 남고 사본이 끌린다(디자이너 관례).
//
// 예전에는 이 제스처가 '선택에 더하고 이동'이었다 — Ctrl 이 선택 추가 수정자였기 때문이다.
// 복사 쪽이 이기는 이유: 같은 컨트롤을 여러 개 놓는 일이 서식 작업의 대부분인데
// (같은 문항의 라디오 5개, 같은 서식의 칸 20개) 그 방법이 복사·붙여넣기뿐이었고,
// 붙여넣기는 항상 최상위 +12,+12 에 떨어져 위치를 다시 잡아야 했다.
{ {
var anchor = Find("CheckBox"); var anchor = Find("CheckBox");
var grabbed = Find("Label"); var grabbed = Find("Label");
@@ -1643,19 +1739,113 @@ public static class EditSmoke
var box = designer.WorldBoundsOf(grabbed); var box = designer.WorldBoundsOf(grabbed);
var from = new Point(box.X + box.Width / 2, box.Y + box.Height / 2); var from = new Point(box.X + box.Width / 2, box.Y + box.Height / 2);
var grabbedX = grabbed.X; var grabbedX = grabbed.X;
var countBefore = designer.Pages[0].Controls.Count;
// Alt 로 정렬 스냅을 끈다 — 스냅이 걸리면 이동량이 가이드에 붙어 델타 단언이 흔들린다 // Alt 로 정렬 스냅을 끈다 — 스냅이 걸리면 이동량이 가이드에 붙어 델타 단언이 흔들린다
var ctrlAlt = new PointerContext(true, false, true, 1); var ctrlAlt = new PointerContext(true, false, true, 1);
designer.Interaction.PointerDown(from, ctrlAlt); designer.Interaction.PointerDown(from, ctrlAlt);
designer.Interaction.PointerMove(new Point(from.X + 30, from.Y), ctrlAlt); designer.Interaction.PointerMove(new Point(from.X + 30, from.Y), ctrlAlt);
designer.Interaction.PointerUp(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}"); $"{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}"); $"{anchorAt.X} → {anchor.X}");
designer.Undo.Undo(); designer.Undo.Undo();
Check("Ctrl+드래그: Undo 한 번으로 사본이 사라진다",
designer.Pages[0].Controls.Count == countBefore,
$"{designer.Pages[0].Controls.Count} (기대 {countBefore})");
designer.Selection.Clear(); designer.Selection.Clear();
} }
// 20-5f2) 패널 안의 것을 Ctrl+드래그하면 사본이 <b>같은 패널 안</b>에 생겨야 한다.
//
// 레거시 라디오는 GroupName 이 없어 <b>직계 부모가 배타 그룹</b>이다(운영 라디오 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<ContainerViewModel>().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);
// ① 그냥 자식을 집으면 <b>패널이 잡힌다</b>(선택 단위 = 패널) — 문항 하나를 통째로 복사한다.
// 이게 기본이어야 하는 이유: 같은 문항을 여러 개 만드는 일이 라디오 작업의 대부분이다.
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<ContainerViewModel>().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}")));
// 사본의 라디오는 <b>새 Id</b> 여야 한다 — 같은 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+드래그하면 <b>그 자식만</b> 복사되고
// 사본은 같은 패널에 남는다 — 문항에 선택지를 하나 더 붙이는 동작이다.
// 사본이 패널 밖으로 나가면 배타 그룹이 갈라져 라디오 두 개가 동시에 켜진다.
var panelAfterUndo = nest.Pages[0].Controls.OfType<ContainerViewModel>().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) 데이터소스 배선 — 전용 행이 붙고, 이 서식의 데이터소스만 후보가 된다 // 20-5g) 데이터소스 배선 — 전용 행이 붙고, 이 서식의 데이터소스만 후보가 된다
{ {
designer.AddControlAt("DataTable", new Point(600, 320)); designer.AddControlAt("DataTable", new Point(600, 320));
@@ -156,8 +156,28 @@ public sealed class InteractionController
if (!downControl.IsSelected) if (!downControl.IsSelected)
{ {
var mates = designer.GroupMatesOf(downControl); 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+드래그 = 복사(디자이너 관례). 원본은 자리에 남고 <b>복제본이 끌린다</b>.
//
// <b>왜 여기서 복제하나.</b> 누름 시점에 하면 Ctrl+클릭(선택 토글)만으로도 사본이 생긴다.
// 드래그 문턱을 넘은 이 지점이 "복사할 뜻"이 확정되는 유일한 자리다.
//
// <b>왜 붙여넣기가 아닌가.</b> Paste 는 항상 최상위 +12,+12 에 놓는다 — 패널 안 라디오를
// 그렇게 복사하면 사본이 패널 밖으로 나가 <b>배타 그룹이 깨진다</b>(레거시 라디오는
// 직계 부모가 그룹이다). 제자리 복제는 부모를 유지하므로 같은 문항 안에 남는다.
if (ctx.Ctrl && designer.DuplicateSelectionInPlace())
{
// 복제가 이미 스냅샷을 찍었다 — 이어지는 이동은 추가로 찍지 않는다.
// 그러면 "복사 후 이동" 이 Undo 두 번이 되고, 한 번만 누른 사용자는
// 원본 자리에 사본이 겹쳐 남은 상태를 보게 된다.
undoCaptured = true;
}
// 복제가 거절되면(표 등) 그냥 이동한다 — 선택은 원본에 남아 있다
BeginMove(); BeginMove();
DoMove(world, ctx); // 전환 프레임부터 즉시 추종 DoMove(world, ctx); // 전환 프레임부터 즉시 추종
} }
+34 -13
View File
@@ -18,7 +18,8 @@ public static class PrintService
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary> /// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
public static void Print(DesignerViewModel designer, string documentName, public static void Print(DesignerViewModel designer, string documentName,
SheetMe.Core.Catalog.ITagValueResolver? tags = null, 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(); var dialog = new System.Windows.Controls.PrintDialog();
// 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한 // 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
@@ -38,7 +39,7 @@ public static class PrintService
return; return;
} }
var document = BuildFixedDocument(designer, tags, fields); var document = BuildFixedDocument(designer, tags, fields, checks);
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}"); dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
} }
@@ -53,7 +54,8 @@ public static class PrintService
/// </summary> /// </summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer, public static FixedDocument BuildFixedDocument(DesignerViewModel designer,
SheetMe.Core.Catalog.ITagValueResolver? tags = null, 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(); var document = new FixedDocument();
foreach (var page in designer.Pages) foreach (var page in designer.Pages)
@@ -66,7 +68,7 @@ public static class PrintService
}; };
// 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) — // 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) —
// 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다. // 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다.
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields)); fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields, checks));
var pageContent = new PageContent(); var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage); ((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
@@ -86,7 +88,8 @@ public static class PrintService
/// </summary> /// </summary>
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true, public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
SheetMe.Core.Catalog.ITagValueResolver? tags = null, 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 var canvas = new Canvas
{ {
@@ -118,7 +121,7 @@ public static class PrintService
} }
var presenter = new ContentPresenter var presenter = new ContentPresenter
{ {
Content = PrintProjection(control, printFilter, tags, fields), Content = PrintProjection(control, printFilter, tags, fields, checks),
Width = Math.Max(1, control.Width), Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height), Height = Math.Max(1, control.Height),
}; };
@@ -149,11 +152,12 @@ public static class PrintService
/// </summary> /// </summary>
private static object PrintProjection(ControlViewModel control, bool printFilter, private static object PrintProjection(ControlViewModel control, bool printFilter,
SheetMe.Core.Catalog.ITagValueResolver? tags, 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) if (control is not ContainerViewModel container)
{ {
return WithTagValue(control, tags, fields); return WithTagValue(control, tags, fields, checks);
} }
ContainerViewModel? copy = container switch ContainerViewModel? copy = container switch
{ {
@@ -173,7 +177,7 @@ public static class PrintService
{ {
continue; continue;
} }
if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected) if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected)
{ {
copy.Children.Add(projected); copy.Children.Add(projected);
} }
@@ -201,22 +205,31 @@ public static class PrintService
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다. /// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
/// </summary> /// </summary>
private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags, 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)
{ {
// 미리보기에서 눌러 본 체크·라디오는 <b>무엇보다 뒤다</b> — 태그가 만든 체크 상태까지 덮는다.
// 그 칸이 어떻게 보이는지 직접 눌러 확인하는 중이므로 사용자의 손이 마지막이다.
bool? clicked = null;
if (control is CheckBoxViewModel or RadioButtonViewModel && checks is not null
&& checks.Overrides.TryGetValue(control.Model.Id, out var state))
{
clicked = state;
}
// 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고, // 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다 // 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다). // (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
var (label, value) = Bound(control, tags, fields); var (label, value) = Bound(control, tags, fields);
if (label is null) if (label is null)
{ {
return control; return clicked is null ? control : Rechecked(control, clicked.Value);
} }
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고 // 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면 // ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다. // "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel) if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{ {
return control; return clicked is null ? control : Rechecked(control, clicked.Value);
} }
// 이미지류(싸인·직인·로고)의 값은 <b>파일 경로</b>다 — Text 가 아니라 경로 속성에 넣어 // 이미지류(싸인·직인·로고)의 값은 <b>파일 경로</b>다 — Text 가 아니라 경로 속성에 넣어
// 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다). // 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다).
@@ -239,7 +252,7 @@ public static class PrintService
// 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString // 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString
// "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다. // "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다.
var upper = value.Text.ToUpperInvariant(); 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 else
{ {
@@ -249,6 +262,14 @@ public static class PrintService
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground); return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
} }
/// <summary>미리보기에서 눌러 본 체크 상태만 갈아 끼운 사본 — 원본 VM·모델은 그대로다</summary>
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);
}
/// <summary> /// <summary>
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다. /// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
/// ///
@@ -1413,6 +1413,71 @@ public sealed class DesignerViewModel : ViewModelBase
RebuildLayerRows(); RebuildLayerRows();
} }
/// <summary>
/// 선택분을 <b>제자리</b>에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다.
///
/// <b>붙여넣기와 다른 점</b>: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너)
/// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 <b>같은 패널 안</b>에 남아
/// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다.
///
/// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로
/// 복제본에는 격자가 없다 — <see cref="FilterPastable"/> 와 같은 이유).
/// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다.
/// </summary>
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<string, string>(StringComparer.Ordinal);
var copies = new List<ControlViewModel>();
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;
}
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary> /// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap) private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{ {
+12 -1
View File
@@ -40,6 +40,12 @@
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0" <TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
MaxWidth="360" TextTrimming="CharacterEllipsis" MaxWidth="360" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/> Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
<!--
미리보기에서 눌러 본 체크·라디오를 원래대로. 문서는 처음부터 안 건드리므로
되돌릴 것이 없고, 여기서 지우는 것은 "눌러 본 흔적"뿐이다.
-->
<Button x:Name="ResetChecksButton" Content="누른 것 초기화" Padding="10,3" Click="OnResetChecks"
IsEnabled="False" ToolTip="미리보기에서 눌러 본 체크·라디오를 서식의 기본 상태로 돌립니다"/>
<Separator/> <Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/> <Button Content="" Padding="8,3" Click="OnZoomOut"/>
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/> <TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
@@ -48,7 +54,12 @@
</ToolBarTray> </ToolBarTray>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center"> <!--
종이를 눌러 본다 — 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
체크·라디오를 실제로 눌러 확인한다. 클릭은 미리보기 안에서만 살고 문서를 바꾸지 않는다.
-->
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center"
MouseLeftButtonUp="OnPaperClick" MouseMove="OnPaperMouseMove">
<StackPanel.LayoutTransform> <StackPanel.LayoutTransform>
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/> <ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
</StackPanel.LayoutTransform> </StackPanel.LayoutTransform>
@@ -2,6 +2,7 @@ using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using SheetMe.Designer.Services; using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels; using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Views; namespace SheetMe.Designer.Views;
@@ -48,6 +49,12 @@ public partial class PreviewWindow : Window
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary> /// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
private SheetMe.Data.Stores.PatientContext? patient; private SheetMe.Data.Stores.PatientContext? patient;
/// <summary>
/// 이 창에서 눌러 본 체크·라디오 — <b>문서에는 쓰지 않는다</b>.
/// 창이 닫히면 사라지고, 캔버스의 서식은 한 글자도 바뀌지 않는다.
/// </summary>
private readonly SheetMe.Core.Catalog.PreviewCheckState checks = new();
#endregion #endregion
#region Constructors #region Constructors
@@ -168,12 +175,15 @@ public partial class PreviewWindow : Window
BorderBrush = System.Windows.Media.Brushes.LightGray, BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1), BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20), 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, Width = page.WidthDip,
Height = page.HeightDip, Height = page.HeightDip,
// 어느 종이를 눌렀는지 되찾는 끈 — 클릭 좌표를 페이지 모델로 옮기는 데 쓴다
Tag = page,
}; };
PagesHost.Children.Add(frame); PagesHost.Children.Add(frame);
} }
ResetChecksButton.IsEnabled = checks.HasAny;
Title = designer.Document.Title.Length > 0 Title = designer.Document.Title.Length > 0
? $"미리보기 — {designer.Document.Title}" ? $"미리보기 — {designer.Document.Title}"
: "미리보기"; : "미리보기";
@@ -181,6 +191,110 @@ public partial class PreviewWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages(); private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
/// <summary>
/// 종이 위 체크·라디오를 눌렀다 — <b>미리보기 안에서만</b> 상태가 바뀐다.
///
/// <b>왜 이 창에서 클릭이 되어야 하나.</b> 레거시 미리보기는 정적 그림이 아니라 런타임 모드
/// (<c>TK_PREVIEW</c>)라서 실제로 눌러 보며 확인한다. 라디오가 배타로 묶였는지는
/// <b>눌러 보는 것 말고 확인할 방법이 없다</b> — 패널로 묶었다고 믿고 저장했다가
/// 임상 화면에서 두 개가 동시에 켜지는 것이 이 서식에서 가장 흔한 사고다.
///
/// <b>왜 문서를 안 건드리나.</b> 확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은
/// 것만으로 저장할 내용이 생긴다. 그래서 상태는 <see cref="checks"/> 에만 쌓고 렌더에서 덮는다.
///
/// <b>왜 WPF 히트테스트가 아닌가.</b> 종이 비주얼은 템플릿으로 만든 그림이라
/// 컨트롤 하나가 여러 요소(테두리·글자·표시)로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
/// 모델 트리를 좌표로 훑으면 캔버스의 <c>HitTestControl</c> 과 같은 규칙을 그대로 쓸 수 있다.
/// </summary>
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();
}
/// <summary>누를 수 있는 것 위에서는 손가락 커서 — 안 그러면 클릭이 되는지 알 수 없다</summary>
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;
/// <summary>
/// 마우스 위치의 누를 수 있는 컨트롤과 <b>그 직계 형제들</b> — 없으면 null.
///
/// 형제 목록까지 함께 내는 이유: 라디오 배타는 직계 부모가 가른다
/// (레거시 RadioButton 은 <c>GroupName</c> 이 없다) — 누른 것만 알면 무엇을 꺼야 할지 모른다.
/// </summary>
private (ControlViewModel Hit, IReadOnlyList<ControlViewModel> 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;
}
/// <summary>
/// 그려진 것 중 좌표 위 최상위 컨트롤 — 컨테이너는 자식 우선.
///
/// 캔버스의 히트테스트와 두 군데가 다르다.
/// ① <c>Locked</c> 를 보지 않는다 — 잠금은 편집 보호일 뿐 런타임에서는 눌린다.
/// ② 그려지지 않은 것(숨김·데이터소스·인쇄 제외)은 뺀다 — 안 보이는 것을 누를 수는 없다.
///
/// 맨 위가 누를 수 없는 것(라벨 등)이면 그대로 아무 일도 없다 — 아래로 흘리지 않는다.
/// WinForms 도 맨 위 컨트롤에만 클릭을 준다.
/// </summary>
private static (ControlViewModel Hit, IReadOnlyList<ControlViewModel> Siblings)? HitInPreview(
IList<ControlViewModel> 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;
}
/// <summary>눌러 본 것을 전부 원래대로 — 문서는 애초에 안 바뀌었으니 되돌릴 것이 없다</summary>
private void OnResetChecks(object sender, RoutedEventArgs e)
{
checks.Clear();
BuildPages();
}
/// <summary> /// <summary>
/// 실제 환자를 골라 태그에 값을 넣는다. /// 실제 환자를 골라 태그에 값을 넣는다.
/// ///
@@ -217,6 +331,10 @@ public partial class PreviewWindow : Window
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label) internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{ {
patient = picked; patient = picked;
// 눌러 본 흔적은 여기서 지운다. 환자가 바뀌면 태그가 만드는 체크 상태도 바뀌는데,
// 손으로 누른 값이 그 위에 남아 있으면 <b>새 환자의 실제 값을 가린다</b> —
// 확인하려고 만든 창이 확인을 막는 셈이다. 서식을 갈아탈 때도 같은 이유로 지운다.
checks.Clear();
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고 // 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
// 가장 민감한 값을 여러 번 꺼내게 된다. // 가장 민감한 값을 여러 번 꺼내게 된다.
tags = picked is null tags = picked is null
@@ -274,7 +392,7 @@ public partial class PreviewWindow : Window
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다. // 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0 PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title ? designer.Document.Title
: designer.Document.FormId, tags, fields); : designer.Document.FormId, tags, fields, checks);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,164 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 미리보기 클릭 — 라디오 배타가 <b>직계 부모 안에서만</b> 일어나는지가 핵심이다.
/// 레거시 RadioButton 은 GroupName 이 없어 부모 컨테이너가 문항 단위이고,
/// 운영 서식의 라디오 98.96% 가 그 형태다.
/// </summary>
[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
}