미리보기를 눌러 볼 수 있게 하고, 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
+12 -1
View File
@@ -40,6 +40,12 @@
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
MaxWidth="360" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
<!--
미리보기에서 눌러 본 체크·라디오를 원래대로. 문서는 처음부터 안 건드리므로
되돌릴 것이 없고, 여기서 지우는 것은 "눌러 본 흔적"뿐이다.
-->
<Button x:Name="ResetChecksButton" Content="누른 것 초기화" Padding="10,3" Click="OnResetChecks"
IsEnabled="False" ToolTip="미리보기에서 눌러 본 체크·라디오를 서식의 기본 상태로 돌립니다"/>
<Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/>
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
@@ -48,7 +54,12 @@
</ToolBarTray>
<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>
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
</StackPanel.LayoutTransform>
@@ -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
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
private SheetMe.Data.Stores.PatientContext? patient;
/// <summary>
/// 이 창에서 눌러 본 체크·라디오 — <b>문서에는 쓰지 않는다</b>.
/// 창이 닫히면 사라지고, 캔버스의 서식은 한 글자도 바뀌지 않는다.
/// </summary>
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();
/// <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>
/// 실제 환자를 골라 태그에 값을 넣는다.
///
@@ -217,6 +331,10 @@ public partial class PreviewWindow : Window
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
patient = picked;
// 눌러 본 흔적은 여기서 지운다. 환자가 바뀌면 태그가 만드는 체크 상태도 바뀌는데,
// 손으로 누른 값이 그 위에 남아 있으면 <b>새 환자의 실제 값을 가린다</b> —
// 확인하려고 만든 창이 확인을 막는 셈이다. 서식을 갈아탈 때도 같은 이유로 지운다.
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)
{